@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,460 @@
1
+ import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { DiscordApi } from './discord-api.mjs';
3
+ import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
4
+
5
+ const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
6
+ const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
7
+
8
+ function socketUrl(value) {
9
+ const url = new URL(value);
10
+ if (url.protocol !== 'wss:') throw new Error('Discord returned an insecure Gateway URL');
11
+ url.searchParams.set('v', '10');
12
+ url.searchParams.set('encoding', 'json');
13
+ return url.href;
14
+ }
15
+
16
+ function addSocketListener(socket, event, listener) {
17
+ if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
18
+ else if (typeof socket.on === 'function') socket.on(event, listener);
19
+ else throw new TypeError('Discord WebSocket does not support events');
20
+ }
21
+
22
+ function eventData(event) {
23
+ if (typeof event === 'string') return event;
24
+ if (typeof event?.data === 'string') return event.data;
25
+ if (Buffer.isBuffer(event)) return event.toString('utf8');
26
+ if (Buffer.isBuffer(event?.data)) return event.data.toString('utf8');
27
+ return null;
28
+ }
29
+
30
+ function gatewayCloseError(code) {
31
+ if (code === 4004) {
32
+ const error = new Error('Discord Bot Token 无效,请重新填写。');
33
+ error.code = 'discord-401';
34
+ return error;
35
+ }
36
+ if (code === 4013 || code === 4014) {
37
+ const error = new Error('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。');
38
+ error.code = 'discord-intents';
39
+ return error;
40
+ }
41
+ const error = new Error(`Discord Gateway closed (${code || 'unknown'})`);
42
+ error.code = 'discord-gateway-closed';
43
+ return error;
44
+ }
45
+
46
+ function stripBotMention(text, botId) {
47
+ if (typeof text !== 'string') return '';
48
+ return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
49
+ }
50
+
51
+ export function normalizeDiscordMessage(message, botId) {
52
+ if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
53
+ const direct = !message.guild_id;
54
+ const addressed = direct
55
+ || message.mentions?.some((mention) => String(mention?.id) === String(botId));
56
+ return {
57
+ messageId: String(message.id),
58
+ senderId: String(message.author.id),
59
+ senderIsBot: message.author.bot === true,
60
+ kind: direct ? 'direct' : 'group',
61
+ conversationId: String(message.channel_id),
62
+ content: stripBotMention(message.content ?? '', botId),
63
+ addressed,
64
+ replyTarget: {
65
+ channelId: String(message.channel_id),
66
+ replyToMessageId: String(message.id),
67
+ },
68
+ };
69
+ }
70
+
71
+ class DiscordBotClient {
72
+ #api;
73
+ #signal;
74
+
75
+ constructor({ api, signal }) {
76
+ this.#api = api;
77
+ this.#signal = signal;
78
+ }
79
+
80
+ async sendText(target, text) {
81
+ const chunks = splitMessageText(text, 1_900);
82
+ let result = null;
83
+ for (const [index, chunk] of chunks.entries()) {
84
+ result = await this.#api.createMessage({
85
+ channelId: target.channelId,
86
+ content: chunk,
87
+ replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
88
+ signal: this.#signal,
89
+ });
90
+ }
91
+ return result;
92
+ }
93
+
94
+ sendTyping(target) {
95
+ return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
96
+ }
97
+
98
+ async openStream(target) {
99
+ const stream = createEditableMessageStream({
100
+ limit: 1_900,
101
+ create: async (content) => {
102
+ const message = await this.#api.createMessage({
103
+ channelId: target.channelId,
104
+ content,
105
+ replyToMessageId: target.replyToMessageId,
106
+ signal: this.#signal,
107
+ });
108
+ return message.id;
109
+ },
110
+ edit: (messageId, content) => this.#api.editMessage({
111
+ channelId: target.channelId,
112
+ messageId,
113
+ content,
114
+ signal: this.#signal,
115
+ }),
116
+ sendRemainder: (content) => this.#api.createMessage({
117
+ channelId: target.channelId,
118
+ content,
119
+ signal: this.#signal,
120
+ }),
121
+ });
122
+ return stream.start();
123
+ }
124
+ }
125
+
126
+ export function createDiscordRuntimeStatus() {
127
+ return {
128
+ startedAt: null,
129
+ ready: false,
130
+ connectionState: 'idle',
131
+ harnessReachable: false,
132
+ lastCheckedAt: null,
133
+ lastConnectedAt: null,
134
+ lastError: null,
135
+ ...createDiscordBridgeStatus(),
136
+ };
137
+ }
138
+
139
+ export class DiscordRuntime {
140
+ #config;
141
+ #token;
142
+ #harness;
143
+ #state;
144
+ #logger;
145
+ #replyTimeoutMs;
146
+ #connectTimeoutMs;
147
+ #createApi;
148
+ #createWebSocket;
149
+ #random;
150
+ #status = createDiscordRuntimeStatus();
151
+ #api = null;
152
+ #bridge = null;
153
+ #abortController = null;
154
+ #socket = null;
155
+ #gatewayUrl = null;
156
+ #resumeUrl = null;
157
+ #sessionId = null;
158
+ #sequence = null;
159
+ #heartbeatTimer = null;
160
+ #heartbeatAcked = true;
161
+ #reconnectTimer = null;
162
+ #reconnectAttempt = 0;
163
+ #generation = 0;
164
+ #stopped = true;
165
+ #starting = null;
166
+
167
+ constructor({
168
+ config,
169
+ token,
170
+ harness,
171
+ state,
172
+ logger = console,
173
+ replyTimeoutMs = 600_000,
174
+ connectTimeoutMs = 20_000,
175
+ createApi = (options) => new DiscordApi(options),
176
+ createWebSocket = (url) => new WebSocket(url),
177
+ random = Math.random,
178
+ }) {
179
+ if (!config || !token || !harness || !state) {
180
+ throw new TypeError('DiscordRuntime requires config, token, Harness, and state');
181
+ }
182
+ if (typeof createWebSocket !== 'function') throw new TypeError('DiscordRuntime requires WebSocket');
183
+ this.#config = config;
184
+ this.#token = token;
185
+ this.#harness = harness;
186
+ this.#state = state;
187
+ this.#logger = logger;
188
+ this.#replyTimeoutMs = replyTimeoutMs;
189
+ this.#connectTimeoutMs = connectTimeoutMs;
190
+ this.#createApi = createApi;
191
+ this.#createWebSocket = createWebSocket;
192
+ this.#random = random;
193
+ }
194
+
195
+ get status() {
196
+ return structuredClone(this.#status);
197
+ }
198
+
199
+ async start() {
200
+ if (this.#status.ready && this.#socket) return this.status;
201
+ if (this.#starting) return this.#starting;
202
+ this.#starting = this.#start().finally(() => {
203
+ this.#starting = null;
204
+ });
205
+ return this.#starting;
206
+ }
207
+
208
+ async #start() {
209
+ await this.stop();
210
+ this.#stopped = false;
211
+ this.#sessionId = null;
212
+ this.#resumeUrl = null;
213
+ this.#sequence = null;
214
+ this.#reconnectAttempt = 0;
215
+ this.#status.startedAt = new Date().toISOString();
216
+ this.#status.connectionState = 'connecting';
217
+ this.#status.lastError = null;
218
+ await this.#harness.ensureRunning();
219
+ this.#status.harnessReachable = true;
220
+ const controller = new AbortController();
221
+ this.#abortController = controller;
222
+ const api = this.#createApi({ token: this.#token });
223
+ this.#api = api;
224
+ try {
225
+ const [bot, gateway] = await Promise.all([
226
+ api.getCurrentUser({ signal: controller.signal }),
227
+ api.getGatewayBot({ signal: controller.signal }),
228
+ ]);
229
+ if (String(bot?.id ?? '') !== this.#config.platformId || bot?.bot !== true) {
230
+ throw new Error('Discord token identity does not match the saved bot');
231
+ }
232
+ this.#gatewayUrl = gateway?.url;
233
+ const client = new DiscordBotClient({ api, signal: controller.signal });
234
+ this.#bridge = new DiscordHarnessBridge({
235
+ bot: client,
236
+ harness: this.#harness,
237
+ state: this.#state,
238
+ status: this.#status,
239
+ logger: this.#logger,
240
+ replyTimeoutMs: this.#replyTimeoutMs,
241
+ });
242
+ let timer;
243
+ try {
244
+ await Promise.race([
245
+ this.#openSocket(false),
246
+ new Promise((_, reject) => {
247
+ timer = setTimeout(() => reject(new Error('Discord Gateway did not become ready in time')), this.#connectTimeoutMs);
248
+ }),
249
+ ]);
250
+ } finally {
251
+ clearTimeout(timer);
252
+ }
253
+ return this.status;
254
+ } catch (error) {
255
+ this.#status.ready = false;
256
+ this.#status.connectionState = 'failed';
257
+ this.#status.lastError = error?.message ?? String(error);
258
+ await this.stop();
259
+ throw error;
260
+ }
261
+ }
262
+
263
+ #openSocket(resume) {
264
+ if (this.#stopped) return Promise.reject(new Error('Discord runtime is stopped'));
265
+ const generation = ++this.#generation;
266
+ const url = socketUrl(resume && this.#resumeUrl ? this.#resumeUrl : this.#gatewayUrl);
267
+ const socket = this.#createWebSocket(url);
268
+ this.#socket = socket;
269
+ let settled = false;
270
+ return new Promise((resolve, reject) => {
271
+ const markReady = () => {
272
+ if (settled || generation !== this.#generation) return;
273
+ settled = true;
274
+ this.#reconnectAttempt = 0;
275
+ const now = Date.now();
276
+ this.#status.ready = true;
277
+ this.#status.connectionState = 'connected';
278
+ this.#status.lastCheckedAt = now;
279
+ this.#status.lastConnectedAt = now;
280
+ this.#status.lastError = null;
281
+ resolve();
282
+ };
283
+ addSocketListener(socket, 'message', (event) => {
284
+ if (generation !== this.#generation || this.#stopped) return;
285
+ const raw = eventData(event);
286
+ if (!raw) return;
287
+ let packet;
288
+ try {
289
+ packet = JSON.parse(raw);
290
+ } catch {
291
+ this.#logger.warn?.('[dsh-im:discord] ignored malformed Gateway JSON');
292
+ return;
293
+ }
294
+ if (Number.isSafeInteger(packet.s)) this.#sequence = packet.s;
295
+ if (packet.op === 10) {
296
+ this.#startHeartbeat(packet.d?.heartbeat_interval, socket, generation);
297
+ if (resume && this.#sessionId) {
298
+ this.#sendGateway(socket, {
299
+ op: 6,
300
+ d: { token: this.#token, session_id: this.#sessionId, seq: this.#sequence },
301
+ });
302
+ } else {
303
+ this.#sendGateway(socket, {
304
+ op: 2,
305
+ d: {
306
+ token: this.#token,
307
+ intents: DISCORD_GATEWAY_INTENTS,
308
+ properties: {
309
+ os: process.platform,
310
+ browser: 'dsh-im',
311
+ device: 'dsh-im',
312
+ },
313
+ },
314
+ });
315
+ }
316
+ return;
317
+ }
318
+ if (packet.op === 11) {
319
+ this.#heartbeatAcked = true;
320
+ this.#status.lastCheckedAt = Date.now();
321
+ return;
322
+ }
323
+ if (packet.op === 1) {
324
+ this.#heartbeat(socket);
325
+ return;
326
+ }
327
+ if (packet.op === 7) {
328
+ socket.close(4000, 'Reconnect requested');
329
+ return;
330
+ }
331
+ if (packet.op === 9) {
332
+ if (packet.d !== true) {
333
+ this.#sessionId = null;
334
+ this.#resumeUrl = null;
335
+ this.#sequence = null;
336
+ }
337
+ socket.close(4000, 'Invalid session');
338
+ return;
339
+ }
340
+ if (packet.op !== 0) return;
341
+ if (packet.t === 'READY') {
342
+ this.#sessionId = packet.d?.session_id ?? null;
343
+ this.#resumeUrl = packet.d?.resume_gateway_url ?? null;
344
+ markReady();
345
+ } else if (packet.t === 'RESUMED') {
346
+ markReady();
347
+ } else if (packet.t === 'MESSAGE_CREATE') {
348
+ const message = normalizeDiscordMessage(packet.d, this.#config.platformId);
349
+ if (message) void this.#bridge?.accept(message);
350
+ }
351
+ });
352
+ addSocketListener(socket, 'close', (event = {}) => {
353
+ if (generation !== this.#generation) return;
354
+ this.#clearHeartbeat();
355
+ if (this.#socket === socket) this.#socket = null;
356
+ if (this.#stopped) {
357
+ if (!settled) reject(new DOMException('Stopped', 'AbortError'));
358
+ return;
359
+ }
360
+ const error = gatewayCloseError(Number(event.code) || 0);
361
+ this.#status.ready = false;
362
+ this.#status.connectionState = 'connecting';
363
+ this.#status.lastError = error.message;
364
+ if (!settled) {
365
+ settled = true;
366
+ reject(error);
367
+ }
368
+ if (error.code === 'discord-401' || error.code === 'discord-intents') {
369
+ this.#status.connectionState = 'failed';
370
+ return;
371
+ }
372
+ this.#scheduleReconnect();
373
+ });
374
+ addSocketListener(socket, 'error', () => {
375
+ if (generation !== this.#generation || this.#stopped) return;
376
+ this.#status.lastError = 'Discord Gateway WebSocket error';
377
+ });
378
+ });
379
+ }
380
+
381
+ #sendGateway(socket, payload) {
382
+ if (socket.readyState !== 1) return;
383
+ socket.send(JSON.stringify(payload));
384
+ }
385
+
386
+ #startHeartbeat(interval, socket, generation) {
387
+ this.#clearHeartbeat();
388
+ if (!Number.isFinite(interval) || interval < 1_000) {
389
+ socket.close(4000, 'Invalid heartbeat interval');
390
+ return;
391
+ }
392
+ this.#heartbeatAcked = true;
393
+ const schedule = (delay) => {
394
+ this.#heartbeatTimer = setTimeout(() => {
395
+ if (this.#stopped || generation !== this.#generation || this.#socket !== socket) return;
396
+ if (!this.#heartbeatAcked) {
397
+ socket.close(4000, 'Heartbeat was not acknowledged');
398
+ return;
399
+ }
400
+ this.#heartbeat(socket);
401
+ schedule(interval);
402
+ }, delay);
403
+ this.#heartbeatTimer?.unref?.();
404
+ };
405
+ schedule(Math.floor(interval * this.#random()));
406
+ }
407
+
408
+ #heartbeat(socket) {
409
+ this.#heartbeatAcked = false;
410
+ this.#sendGateway(socket, { op: 1, d: this.#sequence });
411
+ }
412
+
413
+ #clearHeartbeat() {
414
+ if (this.#heartbeatTimer !== null) clearTimeout(this.#heartbeatTimer);
415
+ this.#heartbeatTimer = null;
416
+ this.#heartbeatAcked = true;
417
+ }
418
+
419
+ #scheduleReconnect() {
420
+ if (this.#stopped || this.#reconnectTimer !== null) return;
421
+ const delay = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
422
+ this.#reconnectAttempt += 1;
423
+ this.#reconnectTimer = setTimeout(() => {
424
+ this.#reconnectTimer = null;
425
+ void this.#openSocket(Boolean(this.#sessionId)).catch((error) => {
426
+ if (this.#stopped) return;
427
+ this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', error);
428
+ this.#scheduleReconnect();
429
+ });
430
+ }, delay);
431
+ this.#reconnectTimer?.unref?.();
432
+ }
433
+
434
+ async stop() {
435
+ this.#stopped = true;
436
+ this.#generation += 1;
437
+ this.#abortController?.abort();
438
+ this.#abortController = null;
439
+ if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
440
+ this.#reconnectTimer = null;
441
+ this.#clearHeartbeat();
442
+ const socket = this.#socket;
443
+ const bridge = this.#bridge;
444
+ this.#socket = null;
445
+ this.#bridge = null;
446
+ this.#api = null;
447
+ try {
448
+ if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
449
+ } catch (error) {
450
+ this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error);
451
+ }
452
+ await Promise.race([
453
+ bridge?.waitForIdle() ?? Promise.resolve(),
454
+ new Promise((resolve) => setTimeout(resolve, 2_000)),
455
+ ]);
456
+ this.#status.ready = false;
457
+ this.#status.connectionState = 'idle';
458
+ return this.status;
459
+ }
460
+ }
@@ -0,0 +1,3 @@
1
+ import { HarnessClient } from '../weixin/harness-client.mjs';
2
+
3
+ export class DiscordHarnessClient extends HarnessClient {}
@@ -0,0 +1,3 @@
1
+ import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
2
+
3
+ export class DiscordStateStore extends ConversationStateStore {}
@@ -0,0 +1,216 @@
1
+ import {
2
+ conversationKey,
3
+ extractText,
4
+ isAllowedSender,
5
+ isBotSender,
6
+ splitText,
7
+ } from './message-utils.mjs';
8
+
9
+ const HELP_TEXT = [
10
+ '北汇星河 AIOS 已连接 DeepSeek Harness。',
11
+ '',
12
+ '直接发送问题即可继续当前会话。',
13
+ '/new 开启一个全新会话',
14
+ '/status 检查连接状态',
15
+ '/help 显示本帮助',
16
+ ].join('\n');
17
+
18
+ export class FeishuHarnessBridge {
19
+ #client;
20
+ #channel;
21
+ #harness;
22
+ #state;
23
+ #queues = new Map();
24
+ #acceptedMessageIds = new Set();
25
+ #status;
26
+ #allowedSenderOpenIds;
27
+ #replyTimeoutMs;
28
+
29
+ constructor({
30
+ client,
31
+ channel,
32
+ harness,
33
+ state,
34
+ status,
35
+ allowedSenderOpenIds = new Set(),
36
+ replyTimeoutMs = 600000,
37
+ }) {
38
+ this.#client = client;
39
+ this.#channel = channel;
40
+ this.#harness = harness;
41
+ this.#state = state;
42
+ this.#status = status;
43
+ this.#allowedSenderOpenIds = allowedSenderOpenIds;
44
+ this.#replyTimeoutMs = replyTimeoutMs;
45
+ }
46
+
47
+ accept(event) {
48
+ const messageId = event?.message?.message_id;
49
+ if (!messageId || isBotSender(event) || event?.message?.message_type !== 'text') return;
50
+ if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
51
+ this.#status.messagesRejected += 1;
52
+ this.#status.lastRejectedAt = new Date().toISOString();
53
+ console.warn('[bridge] ignored a message from a sender outside the allowlist');
54
+ return;
55
+ }
56
+ if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) return;
57
+ this.#acceptedMessageIds.add(messageId);
58
+ const processingReaction = this.#addReaction(messageId, 'OnIt');
59
+
60
+ const key = conversationKey(event);
61
+ const previous = this.#queues.get(key) ?? Promise.resolve();
62
+ const task = previous
63
+ .catch(() => undefined)
64
+ .then(() => this.#handle(event, key))
65
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
66
+ .catch(async (error) => {
67
+ console.error('[bridge] message handling failed:', error.message);
68
+ this.#status.lastError = error.message;
69
+ await this.#finishReaction(messageId, processingReaction, 'ERROR');
70
+ await this.#send(
71
+ event.message.chat_id,
72
+ '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
73
+ ).catch(() => undefined);
74
+ })
75
+ .finally(() => {
76
+ this.#acceptedMessageIds.delete(messageId);
77
+ if (this.#queues.get(key) === task) this.#queues.delete(key);
78
+ });
79
+ this.#queues.set(key, task);
80
+ }
81
+
82
+ async waitForIdle() {
83
+ await Promise.allSettled([...this.#queues.values()]);
84
+ }
85
+
86
+ async #handle(event, key) {
87
+ const messageId = event.message.message_id;
88
+ await this.#state.markSeen(messageId);
89
+ this.#status.lastMessageAt = new Date().toISOString();
90
+ this.#status.messagesReceived += 1;
91
+
92
+ const text = extractText(event);
93
+ if (!text) return;
94
+
95
+ if (text === '/help') {
96
+ await this.#send(event.message.chat_id, HELP_TEXT);
97
+ return;
98
+ }
99
+ if (text === '/new') {
100
+ await this.#state.clearSession(key);
101
+ await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
102
+ return;
103
+ }
104
+ if (text === '/status') {
105
+ await this.#harness.ensureRunning();
106
+ await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
107
+ return;
108
+ }
109
+
110
+ let sessionId = this.#state.sessionFor(key);
111
+ if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
112
+ sessionId = await this.#harness.createSession();
113
+ await this.#state.setSession(key, sessionId);
114
+ }
115
+
116
+ console.info(`[bridge] processing ${event.message.chat_type} message ${messageId} in ${sessionId}`);
117
+ await this.#answerWithStream(event, sessionId, text);
118
+ this.#status.messagesReplied += 1;
119
+ this.#status.lastReplyAt = new Date().toISOString();
120
+ this.#status.lastError = null;
121
+ }
122
+
123
+ async #answerWithStream(event, sessionId, text) {
124
+ const chatId = event.message.chat_id;
125
+ const messageId = event.message.message_id;
126
+ if (!this.#channel?.stream) {
127
+ const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
128
+ for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
129
+ this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
130
+ return;
131
+ }
132
+
133
+ let promptStarted = false;
134
+ let completedAnswer = '';
135
+ try {
136
+ await this.#channel.stream(chatId, {
137
+ markdown: async (controller) => {
138
+ promptStarted = true;
139
+ completedAnswer = await this.#harness.ask(sessionId, text, {
140
+ timeoutMs: this.#replyTimeoutMs,
141
+ onUpdate: async (update) => {
142
+ await controller.setContent(this.#progressText(update));
143
+ this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
144
+ },
145
+ });
146
+ await controller.setContent(completedAnswer);
147
+ },
148
+ }, { replyTo: messageId });
149
+ this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
150
+ } catch (error) {
151
+ this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
152
+ if (completedAnswer) {
153
+ console.warn('[bridge] native Feishu stream failed after generation; sending final text:', error.message);
154
+ for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
155
+ this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
156
+ return;
157
+ }
158
+ if (promptStarted) throw error;
159
+
160
+ console.warn('[bridge] native Feishu stream unavailable; using text fallback:', error.message);
161
+ const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
162
+ for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
163
+ this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
164
+ }
165
+ }
166
+
167
+ #progressText(update) {
168
+ if (update.type === 'text' && update.text) return update.text;
169
+ if (update.type === 'tool') {
170
+ if (update.name === 'web_search') return '_正在搜索网络并整理信息…_';
171
+ return `_正在使用 ${update.name || '工具'}…_`;
172
+ }
173
+ return `_${update.text || '正在处理…'}_`;
174
+ }
175
+
176
+ async #addReaction(messageId, emojiType) {
177
+ if (!this.#channel?.addReaction) return null;
178
+ try {
179
+ const reactionId = await this.#channel.addReaction(messageId, emojiType);
180
+ this.#status.reactionsAdded = (this.#status.reactionsAdded ?? 0) + 1;
181
+ return reactionId;
182
+ } catch (error) {
183
+ this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
184
+ console.warn(`[bridge] unable to add ${emojiType} reaction:`, error.message);
185
+ return null;
186
+ }
187
+ }
188
+
189
+ async #finishReaction(messageId, processingReaction, finalEmojiType) {
190
+ const reactionId = await processingReaction;
191
+ if (reactionId && this.#channel?.removeReaction) {
192
+ try {
193
+ await this.#channel.removeReaction(messageId, reactionId);
194
+ this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
195
+ } catch (error) {
196
+ this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
197
+ console.warn('[bridge] unable to remove processing reaction:', error.message);
198
+ }
199
+ }
200
+ await this.#addReaction(messageId, finalEmojiType);
201
+ }
202
+
203
+ async #send(chatId, text) {
204
+ const response = await this.#client.im.v1.message.create({
205
+ params: { receive_id_type: 'chat_id' },
206
+ data: {
207
+ receive_id: chatId,
208
+ msg_type: 'text',
209
+ content: JSON.stringify({ text }),
210
+ },
211
+ });
212
+ if (response?.code && response.code !== 0) {
213
+ throw new Error(`Feishu send failed: ${response.msg || response.code}`);
214
+ }
215
+ }
216
+ }