@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/dsh-im.mjs +99 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +6211 -0
- package/lib/index.js +13339 -0
- package/package.json +73 -0
- package/plugin-src/client/build.mjs +38 -0
- package/plugin-src/client/channel-logos.js +107 -0
- package/plugin-src/client/channels/dingtalk/api.js +203 -0
- package/plugin-src/client/channels/dingtalk/index.js +709 -0
- package/plugin-src/client/channels/dingtalk/styles.js +140 -0
- package/plugin-src/client/channels/discord/api.js +11 -0
- package/plugin-src/client/channels/discord/index.js +25 -0
- package/plugin-src/client/channels/discord/styles.js +19 -0
- package/plugin-src/client/channels/feishu/api.js +325 -0
- package/plugin-src/client/channels/feishu/index.js +986 -0
- package/plugin-src/client/channels/feishu/styles.js +505 -0
- package/plugin-src/client/channels/qq/api.js +119 -0
- package/plugin-src/client/channels/qq/index.js +402 -0
- package/plugin-src/client/channels/qq/styles.js +19 -0
- package/plugin-src/client/channels/shared/token-api.js +88 -0
- package/plugin-src/client/channels/shared/token-channel.js +277 -0
- package/plugin-src/client/channels/telegram/api.js +11 -0
- package/plugin-src/client/channels/telegram/index.js +25 -0
- package/plugin-src/client/channels/telegram/styles.js +19 -0
- package/plugin-src/client/channels/wecom/api.js +119 -0
- package/plugin-src/client/channels/wecom/index.js +402 -0
- package/plugin-src/client/channels/wecom/styles.js +19 -0
- package/plugin-src/client/channels/weixin/api.js +156 -0
- package/plugin-src/client/channels/weixin/index.js +577 -0
- package/plugin-src/client/channels/weixin/styles.js +120 -0
- package/plugin-src/client/credential-binding.js +117 -0
- package/plugin-src/client/index.js +192 -0
- package/plugin-src/client/lifecycle.js +86 -0
- package/plugin-src/client/styles.js +192 -0
- package/plugin-src/host/build.mjs +24 -0
- package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
- package/plugin-src/host/channels/discord/index.mjs +30 -0
- package/plugin-src/host/channels/discord/production.mjs +17 -0
- package/plugin-src/host/channels/discord/rpc.mjs +21 -0
- package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
- package/plugin-src/host/channels/feishu/controller.mjs +172 -0
- package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
- package/plugin-src/host/channels/feishu/index.mjs +73 -0
- package/plugin-src/host/channels/feishu/production.mjs +138 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
- package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/qq/index.mjs +30 -0
- package/plugin-src/host/channels/qq/production.mjs +109 -0
- package/plugin-src/host/channels/qq/rpc.mjs +146 -0
- package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
- package/plugin-src/host/channels/shared/production.mjs +104 -0
- package/plugin-src/host/channels/shared/rpc.mjs +119 -0
- package/plugin-src/host/channels/telegram/index.mjs +30 -0
- package/plugin-src/host/channels/telegram/production.mjs +17 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
- package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/wecom/index.mjs +36 -0
- package/plugin-src/host/channels/wecom/production.mjs +113 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
- package/plugin-src/host/channels/weixin/index.mjs +39 -0
- package/plugin-src/host/channels/weixin/production.mjs +119 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
- package/plugin-src/host/index.mjs +44 -0
- package/plugin-src/host/rpc-authority.mjs +11 -0
- package/scripts/verify-package.mjs +97 -0
- package/src/channels/dingtalk/config-store.mjs +282 -0
- package/src/channels/dingtalk/device-auth.mjs +237 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
- package/src/channels/dingtalk/harness-client.mjs +299 -0
- package/src/channels/dingtalk/state-store.mjs +212 -0
- package/src/channels/discord/config-store.mjs +24 -0
- package/src/channels/discord/discord-api.mjs +153 -0
- package/src/channels/discord/discord-bridge.mjs +15 -0
- package/src/channels/discord/discord-controller.mjs +16 -0
- package/src/channels/discord/discord-runtime.mjs +460 -0
- package/src/channels/discord/harness-client.mjs +3 -0
- package/src/channels/discord/state-store.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +216 -0
- package/src/channels/feishu/config.mjs +71 -0
- package/src/channels/feishu/feishu-app.mjs +51 -0
- package/src/channels/feishu/feishu-channel.mjs +153 -0
- package/src/channels/feishu/feishu-runtime.mjs +222 -0
- package/src/channels/feishu/harness-client.mjs +268 -0
- package/src/channels/feishu/message-utils.mjs +51 -0
- package/src/channels/feishu/multi-bot-controller.mjs +686 -0
- package/src/channels/feishu/plugin-config-store.mjs +204 -0
- package/src/channels/feishu/plugin-controller.mjs +248 -0
- package/src/channels/feishu/registration-manager.mjs +345 -0
- package/src/channels/feishu/state-store.mjs +71 -0
- package/src/channels/qq/config-store.mjs +171 -0
- package/src/channels/qq/harness-client.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +177 -0
- package/src/channels/qq/qq-controller.mjs +452 -0
- package/src/channels/qq/qq-runtime.mjs +208 -0
- package/src/channels/qq/qr-auth.mjs +24 -0
- package/src/channels/qq/state-store.mjs +96 -0
- package/src/channels/shared/conversation-state-store.mjs +107 -0
- package/src/channels/shared/editable-message-stream.mjs +81 -0
- package/src/channels/shared/text-harness-bridge.mjs +180 -0
- package/src/channels/shared/token-bot-controller.mjs +298 -0
- package/src/channels/shared/token-config-store.mjs +180 -0
- package/src/channels/telegram/config-store.mjs +24 -0
- package/src/channels/telegram/harness-client.mjs +3 -0
- package/src/channels/telegram/state-store.mjs +3 -0
- package/src/channels/telegram/telegram-api.mjs +120 -0
- package/src/channels/telegram/telegram-bridge.mjs +15 -0
- package/src/channels/telegram/telegram-controller.mjs +16 -0
- package/src/channels/telegram/telegram-runtime.mjs +278 -0
- package/src/channels/wecom/config-store.mjs +170 -0
- package/src/channels/wecom/harness-client.mjs +3 -0
- package/src/channels/wecom/qr-auth.mjs +95 -0
- package/src/channels/wecom/state-store.mjs +90 -0
- package/src/channels/wecom/wecom-bridge.mjs +243 -0
- package/src/channels/wecom/wecom-controller.mjs +474 -0
- package/src/channels/wecom/wecom-runtime.mjs +209 -0
- package/src/channels/weixin/config-store.mjs +182 -0
- package/src/channels/weixin/harness-client.mjs +259 -0
- package/src/channels/weixin/state-store.mjs +112 -0
- package/src/channels/weixin/weixin-api.mjs +319 -0
- package/src/channels/weixin/weixin-bridge.mjs +173 -0
- package/src/channels/weixin/weixin-controller.mjs +545 -0
- package/src/channels/weixin/weixin-runtime.mjs +204 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
|
|
4
|
+
export const WEIXIN_PROTOCOL_VERSION = '2.4.6';
|
|
5
|
+
export const DEFAULT_BOT_TYPE = '3';
|
|
6
|
+
|
|
7
|
+
const ILINK_APP_ID = 'bot';
|
|
8
|
+
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
10
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
11
|
+
const LOGIN_STATUSES = new Set([
|
|
12
|
+
'wait',
|
|
13
|
+
'scaned',
|
|
14
|
+
'confirmed',
|
|
15
|
+
'expired',
|
|
16
|
+
'scaned_but_redirect',
|
|
17
|
+
'need_verifycode',
|
|
18
|
+
'verify_code_blocked',
|
|
19
|
+
'binded_redirect',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export class WeixinApiError extends Error {
|
|
23
|
+
constructor(code, message, options = {}) {
|
|
24
|
+
super(message, options);
|
|
25
|
+
this.name = 'WeixinApiError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.status = options.status;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nonEmptyString(value) {
|
|
32
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isWeixinHost(hostname) {
|
|
36
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
37
|
+
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeWeixinApiBaseUrl(value) {
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(value);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new WeixinApiError('invalid-base-url', '微信服务返回了无效的连接地址。');
|
|
46
|
+
}
|
|
47
|
+
if (url.protocol !== 'https:' || !isWeixinHost(url.hostname)
|
|
48
|
+
|| (url.port !== '' && url.port !== '443')) {
|
|
49
|
+
throw new WeixinApiError('untrusted-base-url', '微信服务返回了不受信任的连接地址。');
|
|
50
|
+
}
|
|
51
|
+
url.username = '';
|
|
52
|
+
url.password = '';
|
|
53
|
+
url.search = '';
|
|
54
|
+
url.hash = '';
|
|
55
|
+
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
|
56
|
+
return url.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeWeixinQrUrl(value) {
|
|
60
|
+
const text = nonEmptyString(value);
|
|
61
|
+
if (!text) throw new WeixinApiError('invalid-qr', '微信服务没有返回扫码地址。');
|
|
62
|
+
let url;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(text);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new WeixinApiError('invalid-qr', '微信服务返回了无效的扫码地址。');
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol !== 'https:' || !isWeixinHost(url.hostname)) {
|
|
69
|
+
throw new WeixinApiError('untrusted-qr', '微信服务返回了不受信任的扫码地址。');
|
|
70
|
+
}
|
|
71
|
+
return url.toString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function commonHeaders() {
|
|
75
|
+
return {
|
|
76
|
+
'iLink-App-Id': ILINK_APP_ID,
|
|
77
|
+
'iLink-App-ClientVersion': String(ILINK_CLIENT_VERSION),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function authenticatedHeaders(token) {
|
|
82
|
+
const headers = {
|
|
83
|
+
...commonHeaders(),
|
|
84
|
+
'content-type': 'application/json',
|
|
85
|
+
AuthorizationType: 'ilink_bot_token',
|
|
86
|
+
'X-WECHAT-UIN': Buffer.from(String(randomBytes(4).readUInt32BE(0)), 'utf8').toString('base64'),
|
|
87
|
+
};
|
|
88
|
+
if (nonEmptyString(token)) headers.Authorization = `Bearer ${token.trim()}`;
|
|
89
|
+
return headers;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function baseInfo() {
|
|
93
|
+
return {
|
|
94
|
+
channel_version: WEIXIN_PROTOCOL_VERSION,
|
|
95
|
+
bot_agent: 'DeepSeekHarness/0.1.0',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function abortError(signal) {
|
|
100
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
101
|
+
return new DOMException('The operation was aborted', 'AbortError');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function requestJson(fetchImpl, {
|
|
105
|
+
method,
|
|
106
|
+
baseUrl,
|
|
107
|
+
endpoint,
|
|
108
|
+
body,
|
|
109
|
+
token,
|
|
110
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
111
|
+
signal,
|
|
112
|
+
authenticated = true,
|
|
113
|
+
}) {
|
|
114
|
+
const trustedBase = normalizeWeixinApiBaseUrl(baseUrl);
|
|
115
|
+
const url = new URL(endpoint, trustedBase);
|
|
116
|
+
if (!isWeixinHost(url.hostname)) {
|
|
117
|
+
throw new WeixinApiError('untrusted-endpoint', '拒绝访问不受信任的微信服务地址。');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
let timedOut = false;
|
|
122
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
123
|
+
if (signal?.aborted) throw abortError(signal);
|
|
124
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
125
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
126
|
+
timedOut = true;
|
|
127
|
+
controller.abort();
|
|
128
|
+
}, timeoutMs) : null;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const response = await fetchImpl(url, {
|
|
132
|
+
method,
|
|
133
|
+
headers: authenticated ? authenticatedHeaders(token) : commonHeaders(),
|
|
134
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
135
|
+
signal: controller.signal,
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new WeixinApiError(
|
|
139
|
+
'http-error',
|
|
140
|
+
`微信服务请求失败(HTTP ${response.status})。`,
|
|
141
|
+
{ status: response.status },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return await response.json();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
throw new WeixinApiError('invalid-response', '微信服务返回了无法解析的响应。', { cause: error });
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (signal?.aborted) throw abortError(signal);
|
|
151
|
+
if (timedOut) {
|
|
152
|
+
throw new WeixinApiError('timeout', '微信服务请求超时。', { cause: error });
|
|
153
|
+
}
|
|
154
|
+
if (error instanceof WeixinApiError) throw error;
|
|
155
|
+
throw new WeixinApiError('network-error', '暂时无法访问微信服务。', { cause: error });
|
|
156
|
+
} finally {
|
|
157
|
+
if (timer) clearTimeout(timer);
|
|
158
|
+
signal?.removeEventListener('abort', onAbort);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateLoginResponse(value) {
|
|
163
|
+
if (!value || typeof value !== 'object' || !LOGIN_STATUSES.has(value.status)) {
|
|
164
|
+
throw new WeixinApiError('invalid-login-status', '微信服务返回了无法识别的扫码状态。');
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
170
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
171
|
+
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
|
174
|
+
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
|
175
|
+
const response = await requestJson(fetchImpl, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
baseUrl: WEIXIN_QR_BASE_URL,
|
|
178
|
+
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
|
|
179
|
+
body: { local_token_list: tokens },
|
|
180
|
+
timeoutMs: 10_000,
|
|
181
|
+
signal,
|
|
182
|
+
});
|
|
183
|
+
const qrcode = nonEmptyString(response?.qrcode);
|
|
184
|
+
if (!qrcode) throw new WeixinApiError('invalid-qr', '微信服务没有返回二维码令牌。');
|
|
185
|
+
return {
|
|
186
|
+
qrcode,
|
|
187
|
+
qrcodeUrl: normalizeWeixinQrUrl(response.qrcode_img_content),
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async pollLogin({ qrcode, baseUrl = WEIXIN_QR_BASE_URL, verifyCode, signal }) {
|
|
192
|
+
const qr = nonEmptyString(qrcode);
|
|
193
|
+
if (!qr) throw new TypeError('qrcode is required');
|
|
194
|
+
let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr)}`;
|
|
195
|
+
if (nonEmptyString(verifyCode)) endpoint += `&verify_code=${encodeURIComponent(verifyCode.trim())}`;
|
|
196
|
+
const response = await requestJson(fetchImpl, {
|
|
197
|
+
method: 'GET',
|
|
198
|
+
baseUrl,
|
|
199
|
+
endpoint,
|
|
200
|
+
timeoutMs: DEFAULT_LONG_POLL_TIMEOUT_MS,
|
|
201
|
+
signal,
|
|
202
|
+
authenticated: false,
|
|
203
|
+
});
|
|
204
|
+
return validateLoginResponse(response);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async getUpdates({ baseUrl, token, getUpdatesBuf = '', timeoutMs, signal }) {
|
|
208
|
+
try {
|
|
209
|
+
return await requestJson(fetchImpl, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
baseUrl,
|
|
212
|
+
endpoint: 'ilink/bot/getupdates',
|
|
213
|
+
body: { get_updates_buf: getUpdatesBuf, base_info: baseInfo() },
|
|
214
|
+
token,
|
|
215
|
+
timeoutMs: timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS,
|
|
216
|
+
signal,
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof WeixinApiError && error.code === 'timeout') {
|
|
220
|
+
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf };
|
|
221
|
+
}
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async sendText({ baseUrl, token, toUserId, text, contextToken, runId, signal }) {
|
|
227
|
+
const recipient = nonEmptyString(toUserId);
|
|
228
|
+
const content = nonEmptyString(text);
|
|
229
|
+
if (!recipient || !content) throw new TypeError('toUserId and text are required');
|
|
230
|
+
const response = await requestJson(fetchImpl, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
baseUrl,
|
|
233
|
+
endpoint: 'ilink/bot/sendmessage',
|
|
234
|
+
token,
|
|
235
|
+
signal,
|
|
236
|
+
body: {
|
|
237
|
+
msg: {
|
|
238
|
+
from_user_id: '',
|
|
239
|
+
to_user_id: recipient,
|
|
240
|
+
client_id: `dsh-weixin-${randomUUID()}`,
|
|
241
|
+
message_type: 2,
|
|
242
|
+
message_state: 2,
|
|
243
|
+
item_list: [{ type: 1, text_item: { text: content } }],
|
|
244
|
+
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
|
245
|
+
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
|
|
246
|
+
},
|
|
247
|
+
base_info: baseInfo(),
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
if (response?.ret !== undefined && response.ret !== 0) {
|
|
251
|
+
throw new WeixinApiError('send-rejected', '微信服务拒绝了回复消息。');
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async notifyStart({ baseUrl, token, signal }) {
|
|
257
|
+
const response = await requestJson(fetchImpl, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
baseUrl,
|
|
260
|
+
endpoint: 'ilink/bot/msg/notifystart',
|
|
261
|
+
token,
|
|
262
|
+
signal,
|
|
263
|
+
timeoutMs: 10_000,
|
|
264
|
+
body: { base_info: baseInfo() },
|
|
265
|
+
});
|
|
266
|
+
if (response?.ret !== undefined && response.ret !== 0) {
|
|
267
|
+
throw new WeixinApiError('start-rejected', '微信账号连接启动失败。');
|
|
268
|
+
}
|
|
269
|
+
return response;
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
async notifyStop({ baseUrl, token, signal }) {
|
|
273
|
+
return requestJson(fetchImpl, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
baseUrl,
|
|
276
|
+
endpoint: 'ilink/bot/msg/notifystop',
|
|
277
|
+
token,
|
|
278
|
+
signal,
|
|
279
|
+
timeoutMs: 10_000,
|
|
280
|
+
body: { base_info: baseInfo() },
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function extractWeixinText(message) {
|
|
287
|
+
for (const item of message?.item_list ?? []) {
|
|
288
|
+
if (item?.type === 1 && typeof item.text_item?.text === 'string') {
|
|
289
|
+
const text = item.text_item.text.trim();
|
|
290
|
+
if (text) return text;
|
|
291
|
+
}
|
|
292
|
+
if (item?.type === 3 && typeof item.voice_item?.text === 'string') {
|
|
293
|
+
const text = item.voice_item.text.trim();
|
|
294
|
+
if (text) return text;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function weixinMessageId(message) {
|
|
301
|
+
if (message?.message_id !== undefined && message.message_id !== null) {
|
|
302
|
+
return String(message.message_id);
|
|
303
|
+
}
|
|
304
|
+
return nonEmptyString(message?.client_id);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function splitWeixinText(text, maxChars = 4_000) {
|
|
308
|
+
if (text.length <= maxChars) return [text];
|
|
309
|
+
const chunks = [];
|
|
310
|
+
let remaining = text;
|
|
311
|
+
while (remaining.length > maxChars) {
|
|
312
|
+
let splitAt = remaining.lastIndexOf('\n', maxChars);
|
|
313
|
+
if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars;
|
|
314
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
315
|
+
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
|
|
316
|
+
}
|
|
317
|
+
if (remaining) chunks.push(remaining);
|
|
318
|
+
return chunks;
|
|
319
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractWeixinText,
|
|
3
|
+
splitWeixinText,
|
|
4
|
+
weixinMessageId,
|
|
5
|
+
} from './weixin-api.mjs';
|
|
6
|
+
|
|
7
|
+
const HELP_TEXT = [
|
|
8
|
+
'微信已连接 DeepSeek Harness。',
|
|
9
|
+
'',
|
|
10
|
+
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
|
|
11
|
+
'/new 开启一个全新会话',
|
|
12
|
+
'/status 检查连接状态',
|
|
13
|
+
'/help 显示本帮助',
|
|
14
|
+
].join('\n');
|
|
15
|
+
|
|
16
|
+
function conversationKey(userId) {
|
|
17
|
+
return `p2p:${userId}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createWeixinBridgeStatus() {
|
|
21
|
+
return {
|
|
22
|
+
messagesReceived: 0,
|
|
23
|
+
messagesReplied: 0,
|
|
24
|
+
messagesRejected: 0,
|
|
25
|
+
lastMessageAt: null,
|
|
26
|
+
lastReplyAt: null,
|
|
27
|
+
lastRejectedAt: null,
|
|
28
|
+
lastError: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class WeixinHarnessBridge {
|
|
33
|
+
#api;
|
|
34
|
+
#baseUrl;
|
|
35
|
+
#token;
|
|
36
|
+
#ownerUserId;
|
|
37
|
+
#harness;
|
|
38
|
+
#state;
|
|
39
|
+
#status;
|
|
40
|
+
#logger;
|
|
41
|
+
#replyTimeoutMs;
|
|
42
|
+
#maxMessageChars;
|
|
43
|
+
#queues = new Map();
|
|
44
|
+
|
|
45
|
+
constructor({
|
|
46
|
+
api,
|
|
47
|
+
baseUrl,
|
|
48
|
+
token,
|
|
49
|
+
ownerUserId,
|
|
50
|
+
harness,
|
|
51
|
+
state,
|
|
52
|
+
status = createWeixinBridgeStatus(),
|
|
53
|
+
logger = console,
|
|
54
|
+
replyTimeoutMs = 600_000,
|
|
55
|
+
maxMessageChars = 4_000,
|
|
56
|
+
}) {
|
|
57
|
+
if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
|
|
58
|
+
if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
|
|
59
|
+
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
|
60
|
+
this.#api = api;
|
|
61
|
+
this.#baseUrl = baseUrl;
|
|
62
|
+
this.#token = token;
|
|
63
|
+
this.#ownerUserId = ownerUserId;
|
|
64
|
+
this.#harness = harness;
|
|
65
|
+
this.#state = state;
|
|
66
|
+
this.#status = status;
|
|
67
|
+
this.#logger = logger;
|
|
68
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
69
|
+
this.#maxMessageChars = maxMessageChars;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get status() {
|
|
73
|
+
return structuredClone(this.#status);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
accept(message) {
|
|
77
|
+
const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : '';
|
|
78
|
+
const previous = this.#queues.get(sender) ?? Promise.resolve();
|
|
79
|
+
const current = previous
|
|
80
|
+
.catch(() => undefined)
|
|
81
|
+
.then(() => this.#process(message))
|
|
82
|
+
.finally(() => {
|
|
83
|
+
if (this.#queues.get(sender) === current) this.#queues.delete(sender);
|
|
84
|
+
});
|
|
85
|
+
this.#queues.set(sender, current);
|
|
86
|
+
return current;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async waitForIdle() {
|
|
90
|
+
await Promise.allSettled([...this.#queues.values()]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async #process(message) {
|
|
94
|
+
if (message?.message_type === 2) return;
|
|
95
|
+
const messageId = weixinMessageId(message);
|
|
96
|
+
const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : '';
|
|
97
|
+
if (!messageId || !sender) return;
|
|
98
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
99
|
+
|
|
100
|
+
this.#status.messagesReceived += 1;
|
|
101
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
102
|
+
if (sender !== this.#ownerUserId) {
|
|
103
|
+
this.#status.messagesRejected += 1;
|
|
104
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
|
109
|
+
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
|
110
|
+
const text = extractWeixinText(message);
|
|
111
|
+
try {
|
|
112
|
+
if (!text) {
|
|
113
|
+
await this.#send(sender, '目前仅支持文字消息,以及微信已转成文字的语音消息。', contextToken, runId);
|
|
114
|
+
await this.#state.markSeen(messageId);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const command = text.trim().toLowerCase();
|
|
119
|
+
if (command === '/help') {
|
|
120
|
+
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
|
121
|
+
await this.#state.markSeen(messageId);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (command === '/status') {
|
|
125
|
+
await this.#harness.ensureRunning();
|
|
126
|
+
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
|
127
|
+
await this.#state.markSeen(messageId);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (command === '/new') {
|
|
131
|
+
await this.#state.clearSession(conversationKey(sender));
|
|
132
|
+
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
|
133
|
+
await this.#state.markSeen(messageId);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const key = conversationKey(sender);
|
|
138
|
+
let sessionId = this.#state.sessionFor(key);
|
|
139
|
+
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
|
|
140
|
+
sessionId = await this.#harness.createSession();
|
|
141
|
+
await this.#state.setSession(key, sessionId);
|
|
142
|
+
}
|
|
143
|
+
const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
|
|
144
|
+
await this.#send(sender, answer, contextToken, runId);
|
|
145
|
+
await this.#state.markSeen(messageId);
|
|
146
|
+
this.#status.messagesReplied += 1;
|
|
147
|
+
this.#status.lastReplyAt = new Date().toISOString();
|
|
148
|
+
this.#status.lastError = null;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
151
|
+
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
|
152
|
+
try {
|
|
153
|
+
await this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId);
|
|
154
|
+
await this.#state.markSeen(messageId);
|
|
155
|
+
} catch (sendError) {
|
|
156
|
+
this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async #send(toUserId, text, contextToken, runId) {
|
|
162
|
+
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
|
163
|
+
await this.#api.sendText({
|
|
164
|
+
baseUrl: this.#baseUrl,
|
|
165
|
+
token: this.#token,
|
|
166
|
+
toUserId,
|
|
167
|
+
text: chunk,
|
|
168
|
+
contextToken,
|
|
169
|
+
runId,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|