@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,278 @@
1
+ import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { TelegramApi } from './telegram-api.mjs';
3
+ import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
4
+
5
+ function escaped(value) {
6
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ }
8
+
9
+ function mentionedUsername(message, username) {
10
+ if (!username || typeof message?.text !== 'string' || !Array.isArray(message.entities)) return false;
11
+ return message.entities.some((entity) => {
12
+ if (entity?.type !== 'mention' || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) {
13
+ return false;
14
+ }
15
+ return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
16
+ === `@${username.toLowerCase()}`;
17
+ });
18
+ }
19
+
20
+ function withoutBotMention(text, username) {
21
+ if (!username || typeof text !== 'string') return text;
22
+ return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
23
+ }
24
+
25
+ export function normalizeTelegramUpdate(update, { botId, username }) {
26
+ const message = update?.message;
27
+ const chatId = message?.chat?.id;
28
+ const senderId = message?.from?.id;
29
+ const messageId = message?.message_id;
30
+ if (!Number.isSafeInteger(update?.update_id) || chatId === undefined || senderId === undefined
31
+ || !Number.isSafeInteger(messageId)) return null;
32
+ if (!['private', 'group', 'supergroup'].includes(message.chat?.type)) return null;
33
+ const direct = message.chat.type === 'private';
34
+ const addressed = direct
35
+ || String(message.reply_to_message?.from?.id ?? '') === String(botId)
36
+ || mentionedUsername(message, username);
37
+ return {
38
+ messageId: String(update.update_id),
39
+ senderId: String(senderId),
40
+ senderIsBot: message.from?.is_bot === true,
41
+ kind: direct ? 'direct' : 'group',
42
+ conversationId: String(chatId),
43
+ content: withoutBotMention(message.text ?? message.caption ?? '', username),
44
+ addressed,
45
+ replyTarget: {
46
+ chatId,
47
+ replyToMessageId: messageId,
48
+ messageThreadId: Number.isSafeInteger(message.message_thread_id)
49
+ ? message.message_thread_id : undefined,
50
+ },
51
+ };
52
+ }
53
+
54
+ class TelegramBotClient {
55
+ #api;
56
+ #signal;
57
+
58
+ constructor({ api, signal }) {
59
+ this.#api = api;
60
+ this.#signal = signal;
61
+ }
62
+
63
+ async sendText(target, text) {
64
+ const chunks = splitMessageText(text, 4_000);
65
+ let result = null;
66
+ for (const [index, chunk] of chunks.entries()) {
67
+ result = await this.#api.sendMessage({
68
+ chatId: target.chatId,
69
+ text: chunk,
70
+ replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
71
+ messageThreadId: target.messageThreadId,
72
+ signal: this.#signal,
73
+ });
74
+ }
75
+ return result;
76
+ }
77
+
78
+ sendTyping(target) {
79
+ return this.#api.sendChatAction({
80
+ chatId: target.chatId,
81
+ messageThreadId: target.messageThreadId,
82
+ signal: this.#signal,
83
+ });
84
+ }
85
+
86
+ async openStream(target) {
87
+ const stream = createEditableMessageStream({
88
+ limit: 4_000,
89
+ create: async (text) => {
90
+ const message = await this.#api.sendMessage({
91
+ chatId: target.chatId,
92
+ text,
93
+ replyToMessageId: target.replyToMessageId,
94
+ messageThreadId: target.messageThreadId,
95
+ signal: this.#signal,
96
+ });
97
+ return message.message_id;
98
+ },
99
+ edit: (messageId, text) => this.#api.editMessageText({
100
+ chatId: target.chatId,
101
+ messageId,
102
+ text,
103
+ signal: this.#signal,
104
+ }),
105
+ sendRemainder: (text) => this.#api.sendMessage({
106
+ chatId: target.chatId,
107
+ text,
108
+ messageThreadId: target.messageThreadId,
109
+ signal: this.#signal,
110
+ }),
111
+ });
112
+ return stream.start();
113
+ }
114
+ }
115
+
116
+ export function createTelegramRuntimeStatus() {
117
+ return {
118
+ startedAt: null,
119
+ ready: false,
120
+ connectionState: 'idle',
121
+ harnessReachable: false,
122
+ lastCheckedAt: null,
123
+ lastConnectedAt: null,
124
+ lastError: null,
125
+ ...createTelegramBridgeStatus(),
126
+ };
127
+ }
128
+
129
+ export class TelegramRuntime {
130
+ #config;
131
+ #token;
132
+ #harness;
133
+ #state;
134
+ #logger;
135
+ #replyTimeoutMs;
136
+ #createApi;
137
+ #status = createTelegramRuntimeStatus();
138
+ #api = null;
139
+ #bridge = null;
140
+ #abortController = null;
141
+ #pollTask = null;
142
+ #starting = null;
143
+
144
+ constructor({
145
+ config,
146
+ token,
147
+ harness,
148
+ state,
149
+ logger = console,
150
+ replyTimeoutMs = 600_000,
151
+ createApi = (options) => new TelegramApi(options),
152
+ }) {
153
+ if (!config || !token || !harness || !state) {
154
+ throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
155
+ }
156
+ this.#config = config;
157
+ this.#token = token;
158
+ this.#harness = harness;
159
+ this.#state = state;
160
+ this.#logger = logger;
161
+ this.#replyTimeoutMs = replyTimeoutMs;
162
+ this.#createApi = createApi;
163
+ }
164
+
165
+ get status() {
166
+ return structuredClone(this.#status);
167
+ }
168
+
169
+ async start() {
170
+ if (this.#status.ready && this.#pollTask) return this.status;
171
+ if (this.#starting) return this.#starting;
172
+ this.#starting = this.#start().finally(() => {
173
+ this.#starting = null;
174
+ });
175
+ return this.#starting;
176
+ }
177
+
178
+ async #start() {
179
+ await this.stop();
180
+ this.#status.startedAt = new Date().toISOString();
181
+ this.#status.connectionState = 'connecting';
182
+ this.#status.lastError = null;
183
+ await this.#harness.ensureRunning();
184
+ this.#status.harnessReachable = true;
185
+
186
+ const controller = new AbortController();
187
+ this.#abortController = controller;
188
+ const api = this.#createApi({ token: this.#token });
189
+ this.#api = api;
190
+ try {
191
+ const bot = await api.getMe({ signal: controller.signal });
192
+ if (String(bot?.id ?? '') !== this.#config.platformId || bot?.is_bot !== true) {
193
+ throw new Error('Telegram token identity does not match the saved bot');
194
+ }
195
+ const webhook = await api.getWebhookInfo({ signal: controller.signal });
196
+ if (typeof webhook?.url === 'string' && webhook.url) {
197
+ const error = new Error('该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。');
198
+ error.code = 'webhook-configured';
199
+ throw error;
200
+ }
201
+ const client = new TelegramBotClient({ api, signal: controller.signal });
202
+ this.#bridge = new TelegramHarnessBridge({
203
+ bot: client,
204
+ harness: this.#harness,
205
+ state: this.#state,
206
+ status: this.#status,
207
+ logger: this.#logger,
208
+ replyTimeoutMs: this.#replyTimeoutMs,
209
+ });
210
+
211
+ let cursor = this.#state.cursor();
212
+ if (cursor === null) {
213
+ const latest = await api.getUpdates({ offset: -1, timeout: 0, signal: controller.signal });
214
+ cursor = latest.length > 0 ? latest.at(-1).update_id + 1 : 0;
215
+ await this.#state.setCursor(cursor);
216
+ }
217
+ const now = Date.now();
218
+ this.#status.ready = true;
219
+ this.#status.connectionState = 'connected';
220
+ this.#status.lastCheckedAt = now;
221
+ this.#status.lastConnectedAt = now;
222
+ this.#pollTask = this.#poll(cursor, controller.signal);
223
+ this.#pollTask.catch((error) => {
224
+ if (controller.signal.aborted) return;
225
+ this.#status.ready = false;
226
+ this.#status.connectionState = 'failed';
227
+ this.#status.lastError = error?.message ?? String(error);
228
+ this.#logger.error?.(`[dsh-im:telegram] bot ${this.#config.botId} polling stopped:`, error);
229
+ });
230
+ return this.status;
231
+ } catch (error) {
232
+ this.#status.ready = false;
233
+ this.#status.connectionState = 'failed';
234
+ this.#status.lastError = error?.message ?? String(error);
235
+ await this.stop();
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ async #poll(initialCursor, signal) {
241
+ let cursor = initialCursor;
242
+ while (!signal.aborted) {
243
+ const updates = await this.#api.getUpdates({ offset: cursor, timeout: 25, signal });
244
+ this.#status.lastCheckedAt = Date.now();
245
+ for (const update of updates) {
246
+ if (signal.aborted) return;
247
+ const message = normalizeTelegramUpdate(update, {
248
+ botId: this.#config.platformId,
249
+ username: this.#config.username,
250
+ });
251
+ if (message) await this.#bridge.accept(message);
252
+ cursor = update.update_id + 1;
253
+ await this.#state.setCursor(cursor);
254
+ }
255
+ }
256
+ }
257
+
258
+ async stop() {
259
+ const pollTask = this.#pollTask;
260
+ const bridge = this.#bridge;
261
+ this.#abortController?.abort();
262
+ this.#abortController = null;
263
+ this.#pollTask = null;
264
+ this.#api = null;
265
+ this.#bridge = null;
266
+ await Promise.race([
267
+ pollTask?.catch(() => undefined) ?? Promise.resolve(),
268
+ new Promise((resolve) => setTimeout(resolve, 2_000)),
269
+ ]);
270
+ await Promise.race([
271
+ bridge?.waitForIdle() ?? Promise.resolve(),
272
+ new Promise((resolve) => setTimeout(resolve, 2_000)),
273
+ ]);
274
+ this.#status.ready = false;
275
+ this.#status.connectionState = 'idle';
276
+ return this.status;
277
+ }
278
+ }
@@ -0,0 +1,170 @@
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 safeIntegrationId(value) {
12
+ const id = cleanString(value);
13
+ return id && /^wecom_[a-f0-9]{24}$/.test(id) ? id : null;
14
+ }
15
+
16
+ function safeSecretRef(value) {
17
+ const ref = cleanString(value);
18
+ return ref && /^DSH_WECOM_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null;
19
+ }
20
+
21
+ export function deriveWecomBotIdentity(remoteBotId) {
22
+ const raw = cleanString(remoteBotId);
23
+ if (!raw) throw new TypeError('Enterprise WeChat bot ID is required');
24
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
25
+ return {
26
+ botId: `wecom_${digest}`,
27
+ secretRef: `DSH_WECOM_BOT_SECRET_${digest.toUpperCase()}`,
28
+ };
29
+ }
30
+
31
+ export function maskWecomBotId(remoteBotId) {
32
+ const value = cleanString(remoteBotId) ?? '';
33
+ if (!value) return '企业微信机器人';
34
+ if (value.length <= 10) return `${value.slice(0, 3)}•••`;
35
+ return `${value.slice(0, 6)}••••${value.slice(-4)}`;
36
+ }
37
+
38
+ function normalizeBot(value) {
39
+ if (!value || typeof value !== 'object') return null;
40
+ const botId = safeIntegrationId(value.botId);
41
+ const remoteBotId = cleanString(value.remoteBotId);
42
+ const secretRef = safeSecretRef(value.secretRef);
43
+ if (!botId || !remoteBotId || !secretRef) return null;
44
+ const derived = deriveWecomBotIdentity(remoteBotId);
45
+ if (derived.botId !== botId || derived.secretRef !== secretRef) return null;
46
+ return Object.freeze({
47
+ botId,
48
+ remoteBotId,
49
+ secretRef,
50
+ createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
51
+ connectedAt: cleanString(value.connectedAt),
52
+ });
53
+ }
54
+
55
+ function normalizeDocument(value) {
56
+ if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
57
+ const bots = value.bots.map(normalizeBot);
58
+ if (bots.some((bot) => bot === null)) return null;
59
+ const ids = new Set();
60
+ const remoteIds = new Set();
61
+ const refs = new Set();
62
+ for (const bot of bots) {
63
+ if (ids.has(bot.botId) || remoteIds.has(bot.remoteBotId) || refs.has(bot.secretRef)) return null;
64
+ ids.add(bot.botId);
65
+ remoteIds.add(bot.remoteBotId);
66
+ refs.add(bot.secretRef);
67
+ }
68
+ return Object.freeze({ version: 1, bots: Object.freeze(bots) });
69
+ }
70
+
71
+ export class WecomConfigStore {
72
+ #path;
73
+ #value = EMPTY_DOCUMENT;
74
+ #writeQueue = Promise.resolve();
75
+
76
+ constructor(path) {
77
+ this.#path = path;
78
+ }
79
+
80
+ async load() {
81
+ try {
82
+ const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
83
+ if (!normalized) throw new Error('dsh-im Enterprise WeChat config contains invalid bot data');
84
+ this.#value = normalized;
85
+ } catch (error) {
86
+ if (error?.code !== 'ENOENT') throw error;
87
+ this.#value = EMPTY_DOCUMENT;
88
+ }
89
+ return this;
90
+ }
91
+
92
+ list() {
93
+ return structuredClone(this.#value.bots);
94
+ }
95
+
96
+ get(botId) {
97
+ const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
98
+ return bot ? structuredClone(bot) : null;
99
+ }
100
+
101
+ getByRemoteBotId(remoteBotId) {
102
+ const bot = this.#value.bots.find((candidate) => candidate.remoteBotId === remoteBotId);
103
+ return bot ? structuredClone(bot) : null;
104
+ }
105
+
106
+ async save(value) {
107
+ const normalized = normalizeBot(value);
108
+ if (!normalized) throw new Error('Refusing to persist incomplete Enterprise WeChat bot data');
109
+ return this.#mutate((bots) => {
110
+ const remoteCollision = bots.find(
111
+ (bot) => bot.remoteBotId === normalized.remoteBotId && bot.botId !== normalized.botId,
112
+ );
113
+ const refCollision = bots.find(
114
+ (bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId,
115
+ );
116
+ if (remoteCollision || refCollision) throw new Error('Duplicate Enterprise WeChat bot identity');
117
+ const index = bots.findIndex((bot) => bot.botId === normalized.botId);
118
+ if (index === -1) bots.push(normalized);
119
+ else bots[index] = normalized;
120
+ return structuredClone(normalized);
121
+ });
122
+ }
123
+
124
+ async remove(botId) {
125
+ if (!safeIntegrationId(botId)) throw new TypeError('Invalid Enterprise WeChat bot ID');
126
+ return this.#mutate((bots) => {
127
+ const index = bots.findIndex((bot) => bot.botId === botId);
128
+ if (index === -1) return null;
129
+ const [removed] = bots.splice(index, 1);
130
+ return structuredClone(removed);
131
+ });
132
+ }
133
+
134
+ async clear() {
135
+ const operation = this.#writeQueue.then(async () => {
136
+ try {
137
+ await unlink(this.#path);
138
+ } catch (error) {
139
+ if (error?.code !== 'ENOENT') throw error;
140
+ }
141
+ this.#value = EMPTY_DOCUMENT;
142
+ });
143
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
144
+ await operation;
145
+ }
146
+
147
+ async #mutate(mutator) {
148
+ let result;
149
+ const operation = this.#writeQueue.then(async () => {
150
+ const bots = [...this.#value.bots];
151
+ result = mutator(bots);
152
+ const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
153
+ await this.#write(document);
154
+ this.#value = document;
155
+ });
156
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
157
+ await operation;
158
+ return result;
159
+ }
160
+
161
+ async #write(document) {
162
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
163
+ const temporary = `${this.#path}.tmp`;
164
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
165
+ encoding: 'utf8',
166
+ mode: 0o600,
167
+ });
168
+ await rename(temporary, this.#path);
169
+ }
170
+ }
@@ -0,0 +1,3 @@
1
+ import { HarnessClient } from '../weixin/harness-client.mjs';
2
+
3
+ export class WecomHarnessClient extends HarnessClient {}
@@ -0,0 +1,95 @@
1
+ const GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate';
2
+ const POLL_URL = 'https://work.weixin.qq.com/ai/qc/query_result';
3
+ const QR_TTL_MS = 5 * 60_000;
4
+ const POLL_INTERVAL_MS = 3_000;
5
+
6
+ function defaultPlatform() {
7
+ if (process.platform === 'win32') return 2;
8
+ if (process.platform === 'linux') return 3;
9
+ return 1;
10
+ }
11
+
12
+ function cleanString(value) {
13
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
14
+ }
15
+
16
+ function safeVerificationUrl(value) {
17
+ const raw = cleanString(value);
18
+ if (!raw) return null;
19
+ try {
20
+ const url = new URL(raw);
21
+ return url.protocol === 'https:' && url.hostname === 'work.weixin.qq.com' && (!url.port || url.port === '443')
22
+ ? url.href : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function combinedSignal(signal, timeoutMs) {
29
+ return signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
30
+ }
31
+
32
+ async function requestJson(fetchImpl, url, signal) {
33
+ const response = await fetchImpl(url, {
34
+ method: 'GET',
35
+ redirect: 'error',
36
+ signal: combinedSignal(signal, 10_000),
37
+ headers: { accept: 'application/json' },
38
+ });
39
+ if (!response.ok) throw new Error(`Enterprise WeChat QR service returned HTTP ${response.status}`);
40
+ return response.json();
41
+ }
42
+
43
+ export class WecomQrAuth {
44
+ #fetch;
45
+ #clock;
46
+ #source;
47
+ #platform;
48
+
49
+ constructor({
50
+ fetch: fetchImpl = globalThis.fetch,
51
+ clock = () => Date.now(),
52
+ source = 'deepseek-harness',
53
+ platform = defaultPlatform(),
54
+ } = {}) {
55
+ if (typeof fetchImpl !== 'function') throw new TypeError('A fetch implementation is required');
56
+ this.#fetch = fetchImpl;
57
+ this.#clock = clock;
58
+ this.#source = source;
59
+ this.#platform = [1, 2, 3].includes(platform) ? platform : defaultPlatform();
60
+ }
61
+
62
+ async start({ signal } = {}) {
63
+ const url = new URL(GENERATE_URL);
64
+ url.searchParams.set('source', this.#source);
65
+ url.searchParams.set('plat', String(this.#platform));
66
+ const body = await requestJson(this.#fetch, url, signal);
67
+ const scode = cleanString(body?.data?.scode);
68
+ const verificationUrl = safeVerificationUrl(body?.data?.auth_url);
69
+ if (!scode || !verificationUrl) throw new Error('Enterprise WeChat QR service returned invalid data');
70
+ return {
71
+ scode,
72
+ verificationUrl,
73
+ expiresAt: this.#clock() + QR_TTL_MS,
74
+ pollIntervalMs: POLL_INTERVAL_MS,
75
+ };
76
+ }
77
+
78
+ async poll({ scode, signal } = {}) {
79
+ const code = cleanString(scode);
80
+ if (!code) throw new TypeError('Enterprise WeChat QR poll code is required');
81
+ const url = new URL(POLL_URL);
82
+ url.searchParams.set('scode', code);
83
+ const body = await requestJson(this.#fetch, url, signal);
84
+ const state = cleanString(body?.data?.status)?.toLowerCase();
85
+ if (state === 'success') {
86
+ const remoteBotId = cleanString(body?.data?.bot_info?.botid);
87
+ const secret = cleanString(body?.data?.bot_info?.secret);
88
+ if (!remoteBotId || !secret) throw new Error('Enterprise WeChat QR result omitted bot credentials');
89
+ return { status: 'success', remoteBotId, secret };
90
+ }
91
+ if (['expired', 'timeout'].includes(state)) return { status: 'expired' };
92
+ if (['fail', 'failed', 'error'].includes(state)) return { status: 'failed' };
93
+ return { status: 'waiting' };
94
+ }
95
+ }
@@ -0,0 +1,90 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
5
+
6
+ function normalizeState(value) {
7
+ if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
8
+ const sessions = {};
9
+ if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
10
+ for (const [key, sessionId] of Object.entries(value.sessions)) {
11
+ if (typeof key === 'string' && typeof sessionId === 'string' && sessionId) sessions[key] = sessionId;
12
+ }
13
+ }
14
+ return {
15
+ version: 1,
16
+ sessions,
17
+ seenMessageIds: Array.isArray(value.seenMessageIds)
18
+ ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
19
+ : [],
20
+ };
21
+ }
22
+
23
+ export class WecomStateStore {
24
+ #path;
25
+ #state = structuredClone(EMPTY_STATE);
26
+ #writeQueue = Promise.resolve();
27
+
28
+ constructor(path) {
29
+ this.#path = path;
30
+ }
31
+
32
+ async load() {
33
+ try {
34
+ this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
35
+ } catch (error) {
36
+ if (error?.code !== 'ENOENT') throw error;
37
+ this.#state = structuredClone(EMPTY_STATE);
38
+ await this.#persist();
39
+ }
40
+ return this;
41
+ }
42
+
43
+ sessionFor(key) {
44
+ return this.#state.sessions[key] ?? null;
45
+ }
46
+
47
+ async setSession(key, sessionId) {
48
+ this.#state.sessions[key] = sessionId;
49
+ await this.#persist();
50
+ }
51
+
52
+ async clearSession(key) {
53
+ delete this.#state.sessions[key];
54
+ await this.#persist();
55
+ }
56
+
57
+ hasSeen(messageId) {
58
+ return this.#state.seenMessageIds.includes(messageId);
59
+ }
60
+
61
+ async markSeen(messageId) {
62
+ if (this.hasSeen(messageId)) return;
63
+ this.#state.seenMessageIds.push(messageId);
64
+ if (this.#state.seenMessageIds.length > 1_000) {
65
+ this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
66
+ }
67
+ await this.#persist();
68
+ }
69
+
70
+ async remove() {
71
+ try {
72
+ await unlink(this.#path);
73
+ } catch (error) {
74
+ if (error?.code !== 'ENOENT') throw error;
75
+ }
76
+ this.#state = structuredClone(EMPTY_STATE);
77
+ }
78
+
79
+ async #persist() {
80
+ const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
81
+ const operation = this.#writeQueue.then(async () => {
82
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
83
+ const temporary = `${this.#path}.tmp`;
84
+ await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
85
+ await rename(temporary, this.#path);
86
+ });
87
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
88
+ await operation;
89
+ }
90
+ }