@xmanrui/dsh-im 0.1.0

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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/bin/dsh-im.mjs +99 -0
  5. package/cordis.patch.yml +3 -0
  6. package/lib/client.js +6211 -0
  7. package/lib/index.js +13339 -0
  8. package/package.json +73 -0
  9. package/plugin-src/client/build.mjs +38 -0
  10. package/plugin-src/client/channel-logos.js +107 -0
  11. package/plugin-src/client/channels/dingtalk/api.js +203 -0
  12. package/plugin-src/client/channels/dingtalk/index.js +709 -0
  13. package/plugin-src/client/channels/dingtalk/styles.js +140 -0
  14. package/plugin-src/client/channels/discord/api.js +11 -0
  15. package/plugin-src/client/channels/discord/index.js +25 -0
  16. package/plugin-src/client/channels/discord/styles.js +19 -0
  17. package/plugin-src/client/channels/feishu/api.js +325 -0
  18. package/plugin-src/client/channels/feishu/index.js +986 -0
  19. package/plugin-src/client/channels/feishu/styles.js +505 -0
  20. package/plugin-src/client/channels/qq/api.js +119 -0
  21. package/plugin-src/client/channels/qq/index.js +402 -0
  22. package/plugin-src/client/channels/qq/styles.js +19 -0
  23. package/plugin-src/client/channels/shared/token-api.js +88 -0
  24. package/plugin-src/client/channels/shared/token-channel.js +277 -0
  25. package/plugin-src/client/channels/telegram/api.js +11 -0
  26. package/plugin-src/client/channels/telegram/index.js +25 -0
  27. package/plugin-src/client/channels/telegram/styles.js +19 -0
  28. package/plugin-src/client/channels/wecom/api.js +119 -0
  29. package/plugin-src/client/channels/wecom/index.js +402 -0
  30. package/plugin-src/client/channels/wecom/styles.js +19 -0
  31. package/plugin-src/client/channels/weixin/api.js +156 -0
  32. package/plugin-src/client/channels/weixin/index.js +577 -0
  33. package/plugin-src/client/channels/weixin/styles.js +120 -0
  34. package/plugin-src/client/credential-binding.js +117 -0
  35. package/plugin-src/client/index.js +192 -0
  36. package/plugin-src/client/lifecycle.js +86 -0
  37. package/plugin-src/client/styles.js +192 -0
  38. package/plugin-src/host/build.mjs +24 -0
  39. package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
  40. package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
  43. package/plugin-src/host/channels/discord/index.mjs +30 -0
  44. package/plugin-src/host/channels/discord/production.mjs +17 -0
  45. package/plugin-src/host/channels/discord/rpc.mjs +21 -0
  46. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
  47. package/plugin-src/host/channels/feishu/controller.mjs +172 -0
  48. package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
  49. package/plugin-src/host/channels/feishu/index.mjs +73 -0
  50. package/plugin-src/host/channels/feishu/production.mjs +138 -0
  51. package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
  52. package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
  53. package/plugin-src/host/channels/qq/index.mjs +30 -0
  54. package/plugin-src/host/channels/qq/production.mjs +109 -0
  55. package/plugin-src/host/channels/qq/rpc.mjs +146 -0
  56. package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
  57. package/plugin-src/host/channels/shared/production.mjs +104 -0
  58. package/plugin-src/host/channels/shared/rpc.mjs +119 -0
  59. package/plugin-src/host/channels/telegram/index.mjs +30 -0
  60. package/plugin-src/host/channels/telegram/production.mjs +17 -0
  61. package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
  62. package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
  63. package/plugin-src/host/channels/wecom/index.mjs +36 -0
  64. package/plugin-src/host/channels/wecom/production.mjs +113 -0
  65. package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
  66. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
  67. package/plugin-src/host/channels/weixin/index.mjs +39 -0
  68. package/plugin-src/host/channels/weixin/production.mjs +119 -0
  69. package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
  70. package/plugin-src/host/index.mjs +44 -0
  71. package/plugin-src/host/rpc-authority.mjs +11 -0
  72. package/scripts/verify-package.mjs +97 -0
  73. package/src/channels/dingtalk/config-store.mjs +282 -0
  74. package/src/channels/dingtalk/device-auth.mjs +237 -0
  75. package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
  77. package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
  78. package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
  79. package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
  80. package/src/channels/dingtalk/harness-client.mjs +299 -0
  81. package/src/channels/dingtalk/state-store.mjs +212 -0
  82. package/src/channels/discord/config-store.mjs +24 -0
  83. package/src/channels/discord/discord-api.mjs +153 -0
  84. package/src/channels/discord/discord-bridge.mjs +15 -0
  85. package/src/channels/discord/discord-controller.mjs +16 -0
  86. package/src/channels/discord/discord-runtime.mjs +460 -0
  87. package/src/channels/discord/harness-client.mjs +3 -0
  88. package/src/channels/discord/state-store.mjs +3 -0
  89. package/src/channels/feishu/bridge.mjs +216 -0
  90. package/src/channels/feishu/config.mjs +71 -0
  91. package/src/channels/feishu/feishu-app.mjs +51 -0
  92. package/src/channels/feishu/feishu-channel.mjs +153 -0
  93. package/src/channels/feishu/feishu-runtime.mjs +222 -0
  94. package/src/channels/feishu/harness-client.mjs +268 -0
  95. package/src/channels/feishu/message-utils.mjs +51 -0
  96. package/src/channels/feishu/multi-bot-controller.mjs +686 -0
  97. package/src/channels/feishu/plugin-config-store.mjs +204 -0
  98. package/src/channels/feishu/plugin-controller.mjs +248 -0
  99. package/src/channels/feishu/registration-manager.mjs +345 -0
  100. package/src/channels/feishu/state-store.mjs +71 -0
  101. package/src/channels/qq/config-store.mjs +171 -0
  102. package/src/channels/qq/harness-client.mjs +3 -0
  103. package/src/channels/qq/qq-bridge.mjs +177 -0
  104. package/src/channels/qq/qq-controller.mjs +452 -0
  105. package/src/channels/qq/qq-runtime.mjs +208 -0
  106. package/src/channels/qq/qr-auth.mjs +24 -0
  107. package/src/channels/qq/state-store.mjs +96 -0
  108. package/src/channels/shared/conversation-state-store.mjs +107 -0
  109. package/src/channels/shared/editable-message-stream.mjs +81 -0
  110. package/src/channels/shared/text-harness-bridge.mjs +180 -0
  111. package/src/channels/shared/token-bot-controller.mjs +298 -0
  112. package/src/channels/shared/token-config-store.mjs +180 -0
  113. package/src/channels/telegram/config-store.mjs +24 -0
  114. package/src/channels/telegram/harness-client.mjs +3 -0
  115. package/src/channels/telegram/state-store.mjs +3 -0
  116. package/src/channels/telegram/telegram-api.mjs +120 -0
  117. package/src/channels/telegram/telegram-bridge.mjs +15 -0
  118. package/src/channels/telegram/telegram-controller.mjs +16 -0
  119. package/src/channels/telegram/telegram-runtime.mjs +278 -0
  120. package/src/channels/wecom/config-store.mjs +170 -0
  121. package/src/channels/wecom/harness-client.mjs +3 -0
  122. package/src/channels/wecom/qr-auth.mjs +95 -0
  123. package/src/channels/wecom/state-store.mjs +90 -0
  124. package/src/channels/wecom/wecom-bridge.mjs +243 -0
  125. package/src/channels/wecom/wecom-controller.mjs +474 -0
  126. package/src/channels/wecom/wecom-runtime.mjs +209 -0
  127. package/src/channels/weixin/config-store.mjs +182 -0
  128. package/src/channels/weixin/harness-client.mjs +259 -0
  129. package/src/channels/weixin/state-store.mjs +112 -0
  130. package/src/channels/weixin/weixin-api.mjs +319 -0
  131. package/src/channels/weixin/weixin-bridge.mjs +173 -0
  132. package/src/channels/weixin/weixin-controller.mjs +545 -0
  133. package/src/channels/weixin/weixin-runtime.mjs +204 -0
@@ -0,0 +1,345 @@
1
+ const ACTIVE_STATES = new Set([
2
+ 'starting',
3
+ 'qr_ready',
4
+ 'polling',
5
+ 'slow_down',
6
+ 'domain_switched',
7
+ 'saving',
8
+ ]);
9
+
10
+ const SDK_POLLING_STATES = new Set([
11
+ 'polling',
12
+ 'slow_down',
13
+ 'domain_switched',
14
+ ]);
15
+
16
+ export const REGISTRATION_STATES = Object.freeze({
17
+ IDLE: 'idle',
18
+ STARTING: 'starting',
19
+ QR_READY: 'qr_ready',
20
+ POLLING: 'polling',
21
+ SLOW_DOWN: 'slow_down',
22
+ DOMAIN_SWITCHED: 'domain_switched',
23
+ SAVING: 'saving',
24
+ SUCCEEDED: 'succeeded',
25
+ EXPIRED: 'expired',
26
+ CANCELLED: 'cancelled',
27
+ ERROR: 'error',
28
+ });
29
+
30
+ function errorCode(error) {
31
+ if (['access_denied', 'expired_token', 'abort'].includes(error?.code)) return error.code;
32
+ return 'registration_failed';
33
+ }
34
+
35
+ function publicError(error) {
36
+ const code = errorCode(error);
37
+ const messages = {
38
+ access_denied: 'Registration was denied.',
39
+ abort: 'Registration was cancelled.',
40
+ expired_token: 'The registration QR code expired.',
41
+ };
42
+
43
+ // SDK/network errors are deliberately not copied verbatim. Besides keeping
44
+ // the API stable, this prevents a downstream error from reflecting a secret
45
+ // into a status response.
46
+ return {
47
+ code,
48
+ message: messages[code] ?? 'Unable to register the Feishu app.',
49
+ };
50
+ }
51
+
52
+ function expirySeconds(value) {
53
+ const seconds = Number(value);
54
+ if (!Number.isFinite(seconds) || seconds <= 0) {
55
+ throw new TypeError('registerApp onQRCodeReady returned an invalid expireIn');
56
+ }
57
+ return seconds;
58
+ }
59
+
60
+ function copyUserInfo(userInfo) {
61
+ if (userInfo === undefined) return undefined;
62
+ if (userInfo === null || typeof userInfo !== 'object' || Array.isArray(userInfo)) {
63
+ throw new TypeError('registerApp returned invalid user_info');
64
+ }
65
+ return { ...userInfo };
66
+ }
67
+
68
+ /**
69
+ * Owns one Feishu device-registration attempt at a time.
70
+ *
71
+ * `start()` intentionally does not await the long-running SDK poll. Consumers
72
+ * start an attempt and then poll `status()` until it reaches a terminal state.
73
+ * The App Secret never becomes manager state and is only handed to the injected
74
+ * `onCredentials` callback.
75
+ */
76
+ export class RegistrationManager {
77
+ #registerApp;
78
+ #onCredentials;
79
+ #now;
80
+ #setTimeout;
81
+ #clearTimeout;
82
+ #attempt = 0;
83
+ #active = null;
84
+ #snapshot;
85
+
86
+ constructor({
87
+ registerApp,
88
+ onCredentials,
89
+ now = Date.now,
90
+ setTimeout: setTimeoutFn = globalThis.setTimeout,
91
+ clearTimeout: clearTimeoutFn = globalThis.clearTimeout,
92
+ } = {}) {
93
+ if (typeof registerApp !== 'function') {
94
+ throw new TypeError('RegistrationManager requires a registerApp function');
95
+ }
96
+ if (typeof onCredentials !== 'function') {
97
+ throw new TypeError('RegistrationManager requires an onCredentials function');
98
+ }
99
+ if (typeof now !== 'function' || typeof setTimeoutFn !== 'function' || typeof clearTimeoutFn !== 'function') {
100
+ throw new TypeError('RegistrationManager clock dependencies must be functions');
101
+ }
102
+
103
+ this.#registerApp = registerApp;
104
+ this.#onCredentials = onCredentials;
105
+ this.#now = now;
106
+ this.#setTimeout = setTimeoutFn;
107
+ this.#clearTimeout = clearTimeoutFn;
108
+ this.#snapshot = this.#makeSnapshot(null, REGISTRATION_STATES.IDLE);
109
+ }
110
+
111
+ start(registerOptions = {}) {
112
+ if (registerOptions === null || typeof registerOptions !== 'object' || Array.isArray(registerOptions)) {
113
+ throw new TypeError('Registration options must be an object');
114
+ }
115
+
116
+ this.#supersedeActiveAttempt();
117
+
118
+ const run = {
119
+ id: ++this.#attempt,
120
+ controller: new AbortController(),
121
+ qrCodeUrl: null,
122
+ expiresAt: null,
123
+ pollIntervalSeconds: null,
124
+ expiryTimer: null,
125
+ };
126
+ this.#active = run;
127
+ this.#snapshot = this.#makeSnapshot(run, REGISTRATION_STATES.STARTING);
128
+
129
+ const options = {
130
+ ...registerOptions,
131
+ signal: run.controller.signal,
132
+ onQRCodeReady: (info) => this.#onQRCodeReady(run, info),
133
+ onStatusChange: (info) => this.#onStatusChange(run, info),
134
+ };
135
+
136
+ // Put the SDK invocation on a microtask so a synchronous throw and a
137
+ // Promise rejection follow the same path without making start() blocking.
138
+ const registration = Promise.resolve().then(() => this.#registerApp(options));
139
+ void registration.then(
140
+ (result) => this.#onRegistrationSucceeded(run, result),
141
+ (error) => this.#onRegistrationFailed(run, error),
142
+ );
143
+
144
+ return this.status();
145
+ }
146
+
147
+ status() {
148
+ this.#expireIfNeeded();
149
+
150
+ const snapshot = { ...this.#snapshot };
151
+ if (snapshot.error) snapshot.error = { ...snapshot.error };
152
+
153
+ const run = this.#active;
154
+ if (run && run.expiresAt !== null && ACTIVE_STATES.has(snapshot.state)) {
155
+ snapshot.remainingSeconds = Math.max(0, Math.ceil((run.expiresAt - this.#now()) / 1000));
156
+ }
157
+ return snapshot;
158
+ }
159
+
160
+ cancel() {
161
+ const run = this.#active;
162
+ if (!run) return this.status();
163
+
164
+ this.#finishRun(run, REGISTRATION_STATES.CANCELLED, {
165
+ error: {
166
+ code: 'abort',
167
+ message: 'Registration was cancelled.',
168
+ },
169
+ });
170
+ run.controller.abort();
171
+ return this.status();
172
+ }
173
+
174
+ #isCurrent(run) {
175
+ return this.#active === run;
176
+ }
177
+
178
+ #makeSnapshot(run, state, extra = {}) {
179
+ const snapshot = {
180
+ state,
181
+ attempt: run?.id ?? this.#attempt,
182
+ updatedAt: this.#now(),
183
+ ...extra,
184
+ };
185
+
186
+ if (run?.qrCodeUrl && ACTIVE_STATES.has(state)) {
187
+ snapshot.qrCodeUrl = run.qrCodeUrl;
188
+ snapshot.expiresAt = run.expiresAt;
189
+ }
190
+ if (run?.pollIntervalSeconds !== null && ACTIVE_STATES.has(state)) {
191
+ snapshot.pollIntervalSeconds = run.pollIntervalSeconds;
192
+ }
193
+ return snapshot;
194
+ }
195
+
196
+ #setRunState(run, state, extra = {}) {
197
+ if (!this.#isCurrent(run)) return;
198
+ this.#snapshot = this.#makeSnapshot(run, state, extra);
199
+ }
200
+
201
+ #onQRCodeReady(run, info) {
202
+ if (!this.#isCurrent(run)) return;
203
+ if (typeof info?.url !== 'string' || !info.url) {
204
+ throw new TypeError('registerApp onQRCodeReady returned an invalid URL');
205
+ }
206
+
207
+ const seconds = expirySeconds(info.expireIn);
208
+ run.qrCodeUrl = info.url;
209
+ run.expiresAt = this.#now() + (seconds * 1000);
210
+ this.#clearExpiryTimer(run);
211
+ run.expiryTimer = this.#setTimeout(() => this.#expireRun(run), seconds * 1000);
212
+ run.expiryTimer?.unref?.();
213
+ this.#setRunState(run, REGISTRATION_STATES.QR_READY);
214
+ }
215
+
216
+ #onStatusChange(run, info) {
217
+ if (!this.#isCurrent(run) || !SDK_POLLING_STATES.has(info?.status)) return;
218
+ if (info.status === REGISTRATION_STATES.SLOW_DOWN && Number.isFinite(Number(info.interval))) {
219
+ run.pollIntervalSeconds = Number(info.interval);
220
+ }
221
+ this.#setRunState(run, info.status);
222
+ }
223
+
224
+ async #onRegistrationSucceeded(run, result) {
225
+ if (!this.#isCurrent(run)) return;
226
+
227
+ const clientId = result?.client_id;
228
+ const clientSecret = result?.client_secret;
229
+ if (typeof clientId !== 'string' || !clientId || typeof clientSecret !== 'string' || !clientSecret) {
230
+ this.#finishRun(run, REGISTRATION_STATES.ERROR, {
231
+ error: {
232
+ code: 'invalid_credentials',
233
+ message: 'Feishu registration returned invalid credentials.',
234
+ },
235
+ });
236
+ return;
237
+ }
238
+
239
+ let userInfo;
240
+ try {
241
+ userInfo = copyUserInfo(result.user_info);
242
+ } catch {
243
+ this.#finishRun(run, REGISTRATION_STATES.ERROR, {
244
+ error: {
245
+ code: 'invalid_credentials',
246
+ message: 'Feishu registration returned invalid credentials.',
247
+ },
248
+ });
249
+ return;
250
+ }
251
+
252
+ // Once the SDK has returned credentials, QR expiry no longer applies.
253
+ // Remove the device URL before awaiting persistence so it also disappears
254
+ // from the public `saving` status.
255
+ this.#clearExpiryTimer(run);
256
+ run.qrCodeUrl = null;
257
+ run.expiresAt = null;
258
+ run.pollIntervalSeconds = null;
259
+ this.#setRunState(run, REGISTRATION_STATES.SAVING);
260
+ try {
261
+ await this.#onCredentials({
262
+ client_id: clientId,
263
+ client_secret: clientSecret,
264
+ user_info: userInfo,
265
+ });
266
+ } catch {
267
+ if (this.#isCurrent(run)) {
268
+ this.#finishRun(run, REGISTRATION_STATES.ERROR, {
269
+ error: {
270
+ code: 'credentials_callback_failed',
271
+ message: 'Unable to store the Feishu credentials.',
272
+ },
273
+ });
274
+ }
275
+ return;
276
+ }
277
+
278
+ if (this.#isCurrent(run)) {
279
+ this.#finishRun(run, REGISTRATION_STATES.SUCCEEDED);
280
+ }
281
+ }
282
+
283
+ #onRegistrationFailed(run, error) {
284
+ if (!this.#isCurrent(run)) return;
285
+
286
+ const code = errorCode(error);
287
+ if (code === 'expired_token') {
288
+ this.#finishRun(run, REGISTRATION_STATES.EXPIRED, {
289
+ error: publicError(error),
290
+ });
291
+ return;
292
+ }
293
+ if (code === 'abort') {
294
+ this.#finishRun(run, REGISTRATION_STATES.CANCELLED, {
295
+ error: publicError(error),
296
+ });
297
+ return;
298
+ }
299
+ this.#finishRun(run, REGISTRATION_STATES.ERROR, {
300
+ error: publicError(error),
301
+ });
302
+ }
303
+
304
+ #expireIfNeeded() {
305
+ const run = this.#active;
306
+ if (run && run.expiresAt !== null && this.#now() >= run.expiresAt) {
307
+ this.#expireRun(run);
308
+ }
309
+ }
310
+
311
+ #expireRun(run) {
312
+ if (!this.#isCurrent(run)) return;
313
+ this.#finishRun(run, REGISTRATION_STATES.EXPIRED, {
314
+ error: {
315
+ code: 'expired_token',
316
+ message: 'The registration QR code expired.',
317
+ },
318
+ });
319
+ run.controller.abort();
320
+ }
321
+
322
+ #finishRun(run, state, extra = {}) {
323
+ if (!this.#isCurrent(run)) return;
324
+ this.#clearExpiryTimer(run);
325
+ this.#snapshot = this.#makeSnapshot(run, state, extra);
326
+ this.#active = null;
327
+ }
328
+
329
+ #clearExpiryTimer(run) {
330
+ if (run.expiryTimer !== null) {
331
+ this.#clearTimeout(run.expiryTimer);
332
+ run.expiryTimer = null;
333
+ }
334
+ }
335
+
336
+ #supersedeActiveAttempt() {
337
+ const previous = this.#active;
338
+ if (!previous) return;
339
+ this.#clearExpiryTimer(previous);
340
+ this.#active = null;
341
+ previous.controller.abort();
342
+ }
343
+ }
344
+
345
+ export default RegistrationManager;
@@ -0,0 +1,71 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
5
+
6
+ export class StateStore {
7
+ #path;
8
+ #state = structuredClone(EMPTY_STATE);
9
+ #writeQueue = Promise.resolve();
10
+
11
+ constructor(path) {
12
+ this.#path = path;
13
+ }
14
+
15
+ async load() {
16
+ try {
17
+ const parsed = JSON.parse(await readFile(this.#path, 'utf8'));
18
+ this.#state = {
19
+ version: 1,
20
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
21
+ seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
22
+ };
23
+ } catch (error) {
24
+ if (error?.code !== 'ENOENT') throw error;
25
+ await this.#persist();
26
+ }
27
+ return this;
28
+ }
29
+
30
+ sessionFor(key) {
31
+ return this.#state.sessions[key] ?? null;
32
+ }
33
+
34
+ async setSession(key, sessionId) {
35
+ this.#state.sessions[key] = sessionId;
36
+ await this.#persist();
37
+ }
38
+
39
+ async clearSession(key) {
40
+ delete this.#state.sessions[key];
41
+ await this.#persist();
42
+ }
43
+
44
+ hasSeen(messageId) {
45
+ return this.#state.seenMessageIds.includes(messageId);
46
+ }
47
+
48
+ async markSeen(messageId) {
49
+ if (this.hasSeen(messageId)) return;
50
+ this.#state.seenMessageIds.push(messageId);
51
+ if (this.#state.seenMessageIds.length > 1000) {
52
+ this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1000);
53
+ }
54
+ await this.#persist();
55
+ }
56
+
57
+ snapshot() {
58
+ return structuredClone(this.#state);
59
+ }
60
+
61
+ async #persist() {
62
+ const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
63
+ this.#writeQueue = this.#writeQueue.then(async () => {
64
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
65
+ const temporary = `${this.#path}.tmp`;
66
+ await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
67
+ await rename(temporary, this.#path);
68
+ });
69
+ await this.#writeQueue;
70
+ }
71
+ }
@@ -0,0 +1,171 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+
5
+ const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
6
+
7
+ function cleanString(value) {
8
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
9
+ }
10
+
11
+ function safeBotId(value) {
12
+ const id = cleanString(value);
13
+ return id && /^qq_[a-f0-9]{24}$/.test(id) ? id : null;
14
+ }
15
+
16
+ function safeSecretRef(value) {
17
+ const ref = cleanString(value);
18
+ return ref && /^DSH_QQBOT_APP_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null;
19
+ }
20
+
21
+ export function deriveQqBotIdentity(appId) {
22
+ const raw = cleanString(appId);
23
+ if (!raw) throw new TypeError('appId is required');
24
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
25
+ return {
26
+ botId: `qq_${digest}`,
27
+ secretRef: `DSH_QQBOT_APP_SECRET_${digest.toUpperCase()}`,
28
+ };
29
+ }
30
+
31
+ export function maskQqAppId(appId) {
32
+ const value = cleanString(appId) ?? '';
33
+ if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : 'QQ机器人';
34
+ return `${value.slice(0, 6)}••••${value.slice(-4)}`;
35
+ }
36
+
37
+ function normalizeBot(value) {
38
+ if (!value || typeof value !== 'object') return null;
39
+ const appId = cleanString(value.appId);
40
+ const ownerUserOpenid = cleanString(value.ownerUserOpenid);
41
+ const botId = safeBotId(value.botId);
42
+ const secretRef = safeSecretRef(value.secretRef);
43
+ if (!appId || !ownerUserOpenid || !botId || !secretRef) return null;
44
+ const derived = deriveQqBotIdentity(appId);
45
+ if (derived.botId !== botId || derived.secretRef !== secretRef) return null;
46
+ return Object.freeze({
47
+ botId,
48
+ appId,
49
+ secretRef,
50
+ ownerUserOpenid,
51
+ createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
52
+ connectedAt: cleanString(value.connectedAt),
53
+ });
54
+ }
55
+
56
+ function normalizeDocument(value) {
57
+ if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
58
+ const bots = value.bots.map(normalizeBot);
59
+ if (bots.some((bot) => bot === null)) return null;
60
+ const ids = new Set();
61
+ const appIds = new Set();
62
+ const refs = new Set();
63
+ for (const bot of bots) {
64
+ if (ids.has(bot.botId) || appIds.has(bot.appId) || refs.has(bot.secretRef)) return null;
65
+ ids.add(bot.botId);
66
+ appIds.add(bot.appId);
67
+ refs.add(bot.secretRef);
68
+ }
69
+ return Object.freeze({ version: 1, bots: Object.freeze(bots) });
70
+ }
71
+
72
+ export class QqConfigStore {
73
+ #path;
74
+ #value = EMPTY_DOCUMENT;
75
+ #writeQueue = Promise.resolve();
76
+
77
+ constructor(path) {
78
+ this.#path = path;
79
+ }
80
+
81
+ async load() {
82
+ try {
83
+ const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
84
+ if (!normalized) throw new Error('dsh-im QQ config contains invalid bot data');
85
+ this.#value = normalized;
86
+ } catch (error) {
87
+ if (error?.code !== 'ENOENT') throw error;
88
+ this.#value = EMPTY_DOCUMENT;
89
+ }
90
+ return this;
91
+ }
92
+
93
+ list() {
94
+ return structuredClone(this.#value.bots);
95
+ }
96
+
97
+ get(botId) {
98
+ const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
99
+ return bot ? structuredClone(bot) : null;
100
+ }
101
+
102
+ getByAppId(appId) {
103
+ const bot = this.#value.bots.find((candidate) => candidate.appId === appId);
104
+ return bot ? structuredClone(bot) : null;
105
+ }
106
+
107
+ async save(value) {
108
+ const normalized = normalizeBot(value);
109
+ if (!normalized) throw new Error('Refusing to persist incomplete QQ bot data');
110
+ return this.#mutate((bots) => {
111
+ const appCollision = bots.find(
112
+ (bot) => bot.appId === normalized.appId && bot.botId !== normalized.botId,
113
+ );
114
+ const refCollision = bots.find(
115
+ (bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId,
116
+ );
117
+ if (appCollision || refCollision) throw new Error('Duplicate QQ bot identity');
118
+ const index = bots.findIndex((bot) => bot.botId === normalized.botId);
119
+ if (index === -1) bots.push(normalized);
120
+ else bots[index] = normalized;
121
+ return structuredClone(normalized);
122
+ });
123
+ }
124
+
125
+ async remove(botId) {
126
+ if (!safeBotId(botId)) throw new TypeError('Invalid QQ bot id');
127
+ return this.#mutate((bots) => {
128
+ const index = bots.findIndex((bot) => bot.botId === botId);
129
+ if (index === -1) return null;
130
+ const [removed] = bots.splice(index, 1);
131
+ return structuredClone(removed);
132
+ });
133
+ }
134
+
135
+ async clear() {
136
+ const operation = this.#writeQueue.then(async () => {
137
+ try {
138
+ await unlink(this.#path);
139
+ } catch (error) {
140
+ if (error?.code !== 'ENOENT') throw error;
141
+ }
142
+ this.#value = EMPTY_DOCUMENT;
143
+ });
144
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
145
+ await operation;
146
+ }
147
+
148
+ async #mutate(mutator) {
149
+ let result;
150
+ const operation = this.#writeQueue.then(async () => {
151
+ const bots = [...this.#value.bots];
152
+ result = mutator(bots);
153
+ const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
154
+ await this.#write(document);
155
+ this.#value = document;
156
+ });
157
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
158
+ await operation;
159
+ return result;
160
+ }
161
+
162
+ async #write(document) {
163
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
164
+ const temporary = `${this.#path}.tmp`;
165
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
166
+ encoding: 'utf8',
167
+ mode: 0o600,
168
+ });
169
+ await rename(temporary, this.#path);
170
+ }
171
+ }
@@ -0,0 +1,3 @@
1
+ import { HarnessClient } from '../weixin/harness-client.mjs';
2
+
3
+ export class QqHarnessClient extends HarnessClient {}