@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,281 @@
1
+ import {
2
+ normalizeDingtalkSessionWebhook,
3
+ splitDingtalkText,
4
+ } from './dingtalk-api.mjs';
5
+ import { createDingTalkCardStream } from './dingtalk-card-stream.mjs';
6
+
7
+ const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
8
+ const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
9
+
10
+ const HELP_TEXT = [
11
+ '钉钉机器人已连接 DeepSeek Harness。',
12
+ '',
13
+ '直接发送文字即可继续当前会话。',
14
+ '/new 开启一个全新会话',
15
+ '/status 检查连接状态',
16
+ '/help 显示本帮助',
17
+ ].join('\n');
18
+
19
+ function nonEmptyString(value) {
20
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
21
+ }
22
+
23
+ function senderStaffId(message) {
24
+ return nonEmptyString(message?.senderStaffId) ?? nonEmptyString(message?.senderId);
25
+ }
26
+
27
+ function conversationKey(message, sender) {
28
+ if (String(message?.conversationType) === '2') {
29
+ const conversationId = nonEmptyString(message?.conversationId);
30
+ if (!conversationId) throw new Error('DingTalk group message has no conversation id');
31
+ return `group:${conversationId}`;
32
+ }
33
+ return `p2p:${sender}`;
34
+ }
35
+
36
+ function cardTarget(message, sender) {
37
+ if (String(message?.conversationType) === '2') {
38
+ return { type: 'group', openConversationId: nonEmptyString(message?.conversationId) };
39
+ }
40
+ return { type: 'user', userId: sender };
41
+ }
42
+
43
+ function progressText(update) {
44
+ if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
45
+ if (update?.type === 'tool') {
46
+ if (update.name === 'web_search') return '_正在搜索网络并整理信息…_';
47
+ return `_正在使用 ${nonEmptyString(update.name) ?? '工具'}…_`;
48
+ }
49
+ return `_${nonEmptyString(update?.text) ?? '正在处理…'}_`;
50
+ }
51
+
52
+ function ensureStats(status) {
53
+ status.stats ??= {};
54
+ for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected', 'messagesIgnored']) {
55
+ status[key] ??= 0;
56
+ status.stats[key] = status[key];
57
+ }
58
+ status.pendingSenders ??= [];
59
+ }
60
+
61
+ function increment(status, key) {
62
+ status[key] = (status[key] ?? 0) + 1;
63
+ status.stats ??= {};
64
+ status.stats[key] = status[key];
65
+ }
66
+
67
+ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
68
+ return {
69
+ messagesReceived: 0,
70
+ messagesReplied: 0,
71
+ messagesRejected: 0,
72
+ messagesIgnored: 0,
73
+ lastMessageAt: null,
74
+ lastReplyAt: null,
75
+ lastRejectedAt: null,
76
+ lastError: null,
77
+ pendingSenders: structuredClone(pendingSenders),
78
+ stats: {
79
+ messagesReceived: 0,
80
+ messagesReplied: 0,
81
+ messagesRejected: 0,
82
+ messagesIgnored: 0,
83
+ },
84
+ };
85
+ }
86
+
87
+ export class DingtalkHarnessBridge {
88
+ #api;
89
+ #clientId;
90
+ #clientSecret;
91
+ #harness;
92
+ #state;
93
+ #status;
94
+ #logger;
95
+ #replyTimeoutMs;
96
+ #maxMessageChars;
97
+ #signal;
98
+ #queues = new Map();
99
+ #acceptedMessageIds = new Set();
100
+
101
+ constructor({
102
+ api,
103
+ clientId,
104
+ clientSecret,
105
+ harness,
106
+ state,
107
+ status = createDingtalkBridgeStatus(),
108
+ logger = console,
109
+ replyTimeoutMs = 600_000,
110
+ maxMessageChars = 4_000,
111
+ signal,
112
+ }) {
113
+ if (!api || typeof api.sendText !== 'function') throw new TypeError('DingTalk API is required');
114
+ if (!nonEmptyString(clientId) || !nonEmptyString(clientSecret)) {
115
+ throw new TypeError('DingTalk app credentials are required');
116
+ }
117
+ if (!harness || !state) throw new TypeError('Harness client and state store are required');
118
+ this.#api = api;
119
+ this.#clientId = clientId.trim();
120
+ this.#clientSecret = clientSecret.trim();
121
+ this.#harness = harness;
122
+ this.#state = state;
123
+ this.#status = status;
124
+ this.#logger = logger;
125
+ this.#replyTimeoutMs = replyTimeoutMs;
126
+ this.#maxMessageChars = maxMessageChars;
127
+ this.#signal = signal;
128
+ ensureStats(this.#status);
129
+ this.#refreshPendingSenders();
130
+ }
131
+
132
+ get status() {
133
+ this.#refreshPendingSenders();
134
+ return structuredClone(this.#status);
135
+ }
136
+
137
+ accept(message) {
138
+ if (this.#signal?.aborted) return Promise.resolve();
139
+ const messageId = nonEmptyString(message?.msgId);
140
+ const sender = senderStaffId(message);
141
+ if (!messageId || !sender || this.#state.hasSeen(messageId)
142
+ || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
143
+ this.#acceptedMessageIds.add(messageId);
144
+
145
+ let key;
146
+ try {
147
+ key = conversationKey(message, sender);
148
+ } catch {
149
+ this.#acceptedMessageIds.delete(messageId);
150
+ increment(this.#status, 'messagesRejected');
151
+ this.#status.lastRejectedAt = new Date().toISOString();
152
+ return Promise.resolve();
153
+ }
154
+ const previous = this.#queues.get(key) ?? Promise.resolve();
155
+ const current = previous
156
+ .catch(() => undefined)
157
+ .then(() => this.#process(message, messageId, sender, key))
158
+ .finally(() => {
159
+ this.#acceptedMessageIds.delete(messageId);
160
+ if (this.#queues.get(key) === current) this.#queues.delete(key);
161
+ });
162
+ this.#queues.set(key, current);
163
+ return current;
164
+ }
165
+
166
+ async waitForIdle() {
167
+ await Promise.allSettled([...this.#queues.values()]);
168
+ }
169
+
170
+ async #process(message, messageId, sender, key) {
171
+ this.#signal?.throwIfAborted();
172
+ if (this.#state.hasSeen(messageId)) return;
173
+ await this.#state.markSeen(messageId);
174
+ increment(this.#status, 'messagesReceived');
175
+ this.#status.lastMessageAt = new Date().toISOString();
176
+
177
+ if (String(message.conversationType) === '2' && message.isInAtList !== true) {
178
+ increment(this.#status, 'messagesIgnored');
179
+ return;
180
+ }
181
+
182
+ let sessionWebhook;
183
+ try {
184
+ sessionWebhook = normalizeDingtalkSessionWebhook(message.sessionWebhook);
185
+ } catch {
186
+ increment(this.#status, 'messagesRejected');
187
+ this.#status.lastRejectedAt = new Date().toISOString();
188
+ this.#status.lastError = '钉钉消息没有安全的回复地址。';
189
+ return;
190
+ }
191
+
192
+ const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
193
+ let cardStream = null;
194
+ let cardStarted = false;
195
+ try {
196
+ if (!text) {
197
+ await this.#send(sessionWebhook, '目前仅支持文字消息。');
198
+ return;
199
+ }
200
+
201
+ const command = text.toLowerCase();
202
+ if (command === '/help') {
203
+ await this.#send(sessionWebhook, HELP_TEXT);
204
+ return;
205
+ }
206
+ if (command === '/status') {
207
+ await this.#harness.ensureRunning({ signal: this.#signal });
208
+ await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
209
+ return;
210
+ }
211
+ if (command === '/new') {
212
+ await this.#state.clearSession(key);
213
+ await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
214
+ return;
215
+ }
216
+
217
+ let sessionId = this.#state.sessionFor(key);
218
+ if (!sessionId || !(await this.#harness.sessionExists(sessionId, { signal: this.#signal }))) {
219
+ sessionId = await this.#harness.createSession({ signal: this.#signal });
220
+ await this.#state.setSession(key, sessionId);
221
+ }
222
+ if (typeof this.#api.createAiCard === 'function'
223
+ && typeof this.#api.updateAiCard === 'function'
224
+ && typeof this.#api.finishAiCard === 'function') {
225
+ cardStream = createDingTalkCardStream({
226
+ api: this.#api,
227
+ clientId: this.#clientId,
228
+ clientSecret: this.#clientSecret,
229
+ target: cardTarget(message, sender),
230
+ signal: this.#signal,
231
+ logger: this.#logger,
232
+ });
233
+ cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
234
+ }
235
+ const answer = await this.#harness.ask(sessionId, text, {
236
+ timeoutMs: this.#replyTimeoutMs,
237
+ signal: this.#signal,
238
+ onUpdate: cardStarted
239
+ ? (update) => cardStream.push(progressText(update))
240
+ : undefined,
241
+ });
242
+ const streamed = cardStarted && await cardStream.finish(answer);
243
+ if (!streamed) await this.#send(sessionWebhook, answer);
244
+ increment(this.#status, 'messagesReplied');
245
+ this.#status.lastReplyAt = new Date().toISOString();
246
+ this.#status.lastError = null;
247
+ } catch {
248
+ if (this.#signal?.aborted) return;
249
+ this.#status.lastError = '钉钉消息处理失败。';
250
+ this.#logger.error?.('[dsh-dingtalk] failed to process an inbound message');
251
+ try {
252
+ const streamed = cardStarted && await cardStream.finish(CARD_ERROR_TEXT);
253
+ if (!streamed) await this.#send(sessionWebhook, CARD_ERROR_TEXT);
254
+ } catch {
255
+ this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
256
+ }
257
+ }
258
+ }
259
+
260
+ #refreshPendingSenders() {
261
+ if (typeof this.#state.pendingSenders === 'function') {
262
+ this.#status.pendingSenders = this.#state.pendingSenders();
263
+ }
264
+ }
265
+
266
+ async #send(sessionWebhook, text) {
267
+ for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) {
268
+ this.#signal?.throwIfAborted();
269
+ await this.#api.sendText({
270
+ clientId: this.#clientId,
271
+ clientSecret: this.#clientSecret,
272
+ sessionWebhook,
273
+ text: chunk,
274
+ signal: this.#signal,
275
+ });
276
+ }
277
+ }
278
+ }
279
+
280
+ export const DingTalkHarnessBridge = DingtalkHarnessBridge;
281
+ export const createDingTalkBridgeStatus = createDingtalkBridgeStatus;
@@ -0,0 +1,233 @@
1
+ const DEFAULT_UPDATE_INTERVAL_MS = 500;
2
+ const FAILURE_TEXT = '消息处理失败,请稍后重试。';
3
+
4
+ function requiredText(value, name) {
5
+ if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
6
+ return value;
7
+ }
8
+
9
+ function requiredCredential(value, name) {
10
+ if (typeof value !== 'string' || !value.trim()) {
11
+ throw new TypeError(`${name} is required`);
12
+ }
13
+ return value.trim();
14
+ }
15
+
16
+ /**
17
+ * Creates one throttled DingTalk AI Card update stream.
18
+ *
19
+ * The stream owns a single card instance. Progress updates use latest-wins
20
+ * buffering, while finish waits for an active update before sending the final
21
+ * card content.
22
+ *
23
+ * @param {object} options Stream dependencies and DingTalk request data.
24
+ * @param {object} options.api DingTalk AI Card API implementation.
25
+ * @param {string} options.clientId DingTalk application client id.
26
+ * @param {string} options.clientSecret DingTalk application client secret.
27
+ * @param {unknown} options.target DingTalk card delivery target.
28
+ * @param {AbortSignal} [options.signal] Stream cancellation signal.
29
+ * @param {object} [options.logger] Safe diagnostic sink.
30
+ * @param {number} [options.updateIntervalMs=500] Minimum delay between updates.
31
+ * @param {()=>number} [options.clock] Monotonic millisecond clock.
32
+ * @param {{setTimeout: Function, clearTimeout: Function}} [options.timer] Timer implementation.
33
+ * @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>}}
34
+ * Card stream controller.
35
+ */
36
+ export function createDingTalkCardStream({
37
+ api,
38
+ clientId,
39
+ clientSecret,
40
+ target,
41
+ signal,
42
+ logger = console,
43
+ updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS,
44
+ clock = () => Date.now(),
45
+ timer = {
46
+ setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
47
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
48
+ },
49
+ } = {}) {
50
+ if (!api
51
+ || typeof api.createAiCard !== 'function'
52
+ || typeof api.updateAiCard !== 'function'
53
+ || typeof api.finishAiCard !== 'function') {
54
+ throw new TypeError('DingTalk AI Card API is required');
55
+ }
56
+ const normalizedClientId = requiredCredential(clientId, 'clientId');
57
+ const normalizedClientSecret = requiredCredential(clientSecret, 'clientSecret');
58
+ if (target === undefined || target === null) throw new TypeError('target is required');
59
+ if (!Number.isFinite(updateIntervalMs) || updateIntervalMs < 0) {
60
+ throw new TypeError('updateIntervalMs must be a non-negative number');
61
+ }
62
+ if (typeof clock !== 'function') throw new TypeError('clock must be a function');
63
+ if (typeof timer?.setTimeout !== 'function' || typeof timer?.clearTimeout !== 'function') {
64
+ throw new TypeError('timer must provide setTimeout and clearTimeout');
65
+ }
66
+
67
+ const readClock = () => {
68
+ const value = clock();
69
+ if (!Number.isFinite(value)) throw new TypeError('clock must return a finite timestamp');
70
+ return value;
71
+ };
72
+ readClock();
73
+
74
+ let phase = signal?.aborted ? 'aborted' : 'idle';
75
+ let cardRequest = null;
76
+ let pendingText = null;
77
+ let scheduledUpdate = null;
78
+ let updateWorker = null;
79
+ let finishPromise = null;
80
+ let cleanupPromise = null;
81
+ let lastUpdateAt = 0;
82
+
83
+ const clearScheduledUpdate = () => {
84
+ if (scheduledUpdate === null) return;
85
+ timer.clearTimeout(scheduledUpdate);
86
+ scheduledUpdate = null;
87
+ };
88
+
89
+ const removeAbortListener = () => signal?.removeEventListener('abort', onAbort);
90
+
91
+ const close = (nextPhase) => {
92
+ phase = nextPhase;
93
+ pendingText = null;
94
+ clearScheduledUpdate();
95
+ removeAbortListener();
96
+ };
97
+
98
+ const cleanupCard = () => {
99
+ if (!cardRequest || typeof api.failAiCard !== 'function') return Promise.resolve(false);
100
+ if (!cleanupPromise) {
101
+ cleanupPromise = api.failAiCard({
102
+ ...cardRequest,
103
+ text: FAILURE_TEXT,
104
+ signal: AbortSignal.timeout(5_000),
105
+ }).then(
106
+ () => true,
107
+ () => false,
108
+ );
109
+ }
110
+ return cleanupPromise;
111
+ };
112
+
113
+ const fail = (operation) => {
114
+ if (phase === 'failed' || phase === 'finished' || phase === 'aborted') return;
115
+ void cleanupCard();
116
+ close('failed');
117
+ logger?.error?.(`[dsh-dingtalk] AI Card ${operation} failed`);
118
+ };
119
+
120
+ function onAbort() {
121
+ if (phase === 'finished' || phase === 'failed' || phase === 'aborted') return;
122
+ void cleanupCard();
123
+ close('aborted');
124
+ }
125
+
126
+ if (phase !== 'aborted') signal?.addEventListener('abort', onAbort, { once: true });
127
+
128
+ const launchUpdate = () => {
129
+ if (phase !== 'active' || updateWorker || pendingText === null) return;
130
+ const delay = Math.max(0, lastUpdateAt + updateIntervalMs - readClock());
131
+ if (delay > 0) {
132
+ scheduledUpdate = timer.setTimeout(() => {
133
+ scheduledUpdate = null;
134
+ launchUpdate();
135
+ }, delay);
136
+ return;
137
+ }
138
+
139
+ const text = pendingText;
140
+ pendingText = null;
141
+ updateWorker = (async () => {
142
+ try {
143
+ await api.updateAiCard({ ...cardRequest, text, finished: false });
144
+ lastUpdateAt = readClock();
145
+ } catch {
146
+ if (signal?.aborted || phase === 'aborted') return;
147
+ fail('update');
148
+ }
149
+ })().finally(() => {
150
+ updateWorker = null;
151
+ if (phase === 'active' && pendingText !== null) launchUpdate();
152
+ });
153
+ };
154
+
155
+ const start = async (initialText) => {
156
+ requiredText(initialText, 'initialText');
157
+ if (phase !== 'idle') return false;
158
+ phase = 'starting';
159
+ try {
160
+ const created = await api.createAiCard({
161
+ clientId: normalizedClientId,
162
+ clientSecret: normalizedClientSecret,
163
+ target,
164
+ initialText,
165
+ signal,
166
+ });
167
+ const cardInstanceId = typeof created?.cardInstanceId === 'string'
168
+ ? created.cardInstanceId.trim()
169
+ : '';
170
+ if (!cardInstanceId) throw new TypeError('DingTalk did not return a card instance id');
171
+ cardRequest = Object.freeze({
172
+ clientId: normalizedClientId,
173
+ clientSecret: normalizedClientSecret,
174
+ target,
175
+ cardInstanceId,
176
+ signal,
177
+ });
178
+ if (phase !== 'starting') {
179
+ void cleanupCard();
180
+ return false;
181
+ }
182
+ lastUpdateAt = readClock();
183
+ phase = 'active';
184
+ return true;
185
+ } catch {
186
+ if (signal?.aborted || phase === 'aborted') {
187
+ close('aborted');
188
+ return false;
189
+ }
190
+ fail('creation');
191
+ return false;
192
+ }
193
+ };
194
+
195
+ const push = (progressText) => {
196
+ requiredText(progressText, 'progressText');
197
+ if (phase !== 'active') return;
198
+ pendingText = progressText;
199
+ if (!scheduledUpdate && !updateWorker) launchUpdate();
200
+ };
201
+
202
+ const finish = (finalText) => {
203
+ requiredText(finalText, 'finalText');
204
+ if (phase === 'finished') return Promise.resolve(true);
205
+ if (phase === 'finishing') return finishPromise;
206
+ if (phase !== 'active') return Promise.resolve(false);
207
+
208
+ phase = 'finishing';
209
+ pendingText = null;
210
+ clearScheduledUpdate();
211
+ const activeUpdate = updateWorker;
212
+ finishPromise = (async () => {
213
+ if (activeUpdate) await activeUpdate;
214
+ if (phase !== 'finishing') return false;
215
+ try {
216
+ await api.finishAiCard({ ...cardRequest, text: finalText });
217
+ if (phase !== 'finishing') return false;
218
+ close('finished');
219
+ return true;
220
+ } catch {
221
+ if (signal?.aborted || phase === 'aborted') {
222
+ close('aborted');
223
+ return false;
224
+ }
225
+ fail('finish');
226
+ return false;
227
+ }
228
+ })();
229
+ return finishPromise;
230
+ };
231
+
232
+ return Object.freeze({ start, push, finish });
233
+ }