@xmanrui/dsh-im 4.14.0 → 4.16.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/README.en.md +10 -2
- package/README.md +17 -6
- package/lib/client.js +1980 -616
- package/lib/index.js +303 -293
- package/package.json +6 -2
- package/plugin-src/client/access-policy-settings.js +6 -0
- package/plugin-src/client/channels/feishu/index.js +16 -4
- package/plugin-src/client/channels/wecom-app/api.js +124 -0
- package/plugin-src/client/channels/wecom-app/index.js +612 -0
- package/plugin-src/client/channels/wecom-app/styles.js +25 -0
- package/plugin-src/client/delivery-settings.js +7 -0
- package/plugin-src/client/i18n.js +64 -1
- package/plugin-src/client/index.js +21 -0
- package/plugin-src/client/model-setting.js +135 -69
- package/plugin-src/client/session-channel-logos.js +241 -0
- package/plugin-src/client/styles.js +22 -7
- package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
- package/plugin-src/host/channels/discord/index.mjs +8 -10
- package/plugin-src/host/channels/feishu/index.mjs +8 -15
- package/plugin-src/host/channels/office/index.mjs +9 -5
- package/plugin-src/host/channels/qq/index.mjs +8 -15
- package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
- package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
- package/plugin-src/host/channels/shared/startup.mjs +47 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
- package/plugin-src/host/channels/slack/index.mjs +8 -10
- package/plugin-src/host/channels/telegram/index.mjs +8 -10
- package/plugin-src/host/channels/wecom/index.mjs +8 -15
- package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
- package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
- package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
- package/plugin-src/host/channels/weixin/index.mjs +8 -15
- package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
- package/plugin-src/host/delivery-adapter.mjs +4 -0
- package/plugin-src/host/delivery-suggestions.mjs +4 -0
- package/plugin-src/host/index.mjs +21 -2
- package/plugin-src/host/session-title-prefix.mjs +122 -0
- package/scripts/verify-model-setting.mjs +54 -0
- package/scripts/verify-session-channel-logos.mjs +59 -0
- package/scripts/verify-session-title-prefix.mjs +149 -0
- package/src/channels/feishu/bridge.mjs +305 -12
- package/src/channels/feishu/feishu-cards.mjs +38 -0
- package/src/channels/feishu/feishu-channel.mjs +88 -20
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/feishu/repair-manager.mjs +3 -2
- package/src/channels/shared/bot-workspace-store.mjs +11 -3
- package/src/channels/shared/command-catalog.mjs +102 -0
- package/src/channels/shared/i18n-en/feishu.mjs +14 -2
- package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
- package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/model-setting.mjs +43 -8
- package/src/channels/shared/session-channel-labels.mjs +26 -0
- package/src/channels/shared/text-harness-bridge.mjs +2 -26
- package/src/channels/telegram/telegram-api.mjs +18 -6
- package/src/channels/telegram/telegram-runtime.mjs +34 -32
- package/src/channels/wecom/send-error.mjs +31 -0
- package/src/channels/wecom/wecom-bridge.mjs +59 -17
- package/src/channels/wecom-app/callback-server.mjs +471 -0
- package/src/channels/wecom-app/config-store.mjs +240 -0
- package/src/channels/wecom-app/harness-client.mjs +11 -0
- package/src/channels/wecom-app/state-store.mjs +107 -0
- package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
- package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
- package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
- package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
|
|
6
|
+
|
|
7
|
+
function normalizeState(value) {
|
|
8
|
+
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
|
9
|
+
const sessions = {};
|
|
10
|
+
if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
|
|
11
|
+
for (const [key, sessionId] of Object.entries(value.sessions)) {
|
|
12
|
+
if (typeof key === 'string' && typeof sessionId === 'string' && sessionId) sessions[key] = sessionId;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
version: 1,
|
|
17
|
+
sessions,
|
|
18
|
+
...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
|
|
19
|
+
seenMessageIds: Array.isArray(value.seenMessageIds)
|
|
20
|
+
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
|
21
|
+
: [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class WecomAppStateStore {
|
|
26
|
+
#path;
|
|
27
|
+
#state = structuredClone(EMPTY_STATE);
|
|
28
|
+
#writeQueue = Promise.resolve();
|
|
29
|
+
#deferred = deferredStateAccess(() => this.#state, () => this.#persist());
|
|
30
|
+
|
|
31
|
+
constructor(path) {
|
|
32
|
+
this.#path = path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async load() {
|
|
36
|
+
try {
|
|
37
|
+
this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
40
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
41
|
+
await this.#persist();
|
|
42
|
+
}
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
deferredEntries() { return this.#deferred.entries(); }
|
|
47
|
+
putDeferred(entry) { return this.#deferred.put(entry); }
|
|
48
|
+
patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
|
|
49
|
+
removeDeferred(id) { return this.#deferred.remove(id); }
|
|
50
|
+
|
|
51
|
+
sessionFor(key) {
|
|
52
|
+
return this.#state.sessions[key] ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async setSession(key, sessionId) {
|
|
56
|
+
this.#state.sessions[key] = sessionId;
|
|
57
|
+
await this.#persist();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async clearSession(key) {
|
|
61
|
+
delete this.#state.sessions[key];
|
|
62
|
+
await this.#persist();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async clearSessions() {
|
|
66
|
+
this.#state.sessions = {};
|
|
67
|
+
await this.#persist();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
hasSeen(messageId) {
|
|
71
|
+
return this.#state.seenMessageIds.includes(messageId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async markSeen(messageId) {
|
|
75
|
+
if (this.hasSeen(messageId)) return;
|
|
76
|
+
this.#state.seenMessageIds.push(messageId);
|
|
77
|
+
if (this.#state.seenMessageIds.length > 1_000) {
|
|
78
|
+
this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
|
|
79
|
+
}
|
|
80
|
+
await this.#persist();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
snapshot() {
|
|
84
|
+
return structuredClone(this.#state);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async remove() {
|
|
88
|
+
try {
|
|
89
|
+
await unlink(this.#path);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
92
|
+
}
|
|
93
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #persist() {
|
|
97
|
+
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
|
|
98
|
+
const operation = this.#writeQueue.then(async () => {
|
|
99
|
+
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
100
|
+
const temporary = `${this.#path}.tmp`;
|
|
101
|
+
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
102
|
+
await rename(temporary, this.#path);
|
|
103
|
+
});
|
|
104
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
105
|
+
await operation;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { WecomCrypto } from '@wecom/aibot-node-sdk';
|
|
2
|
+
|
|
3
|
+
import { t } from '../shared/i18n.mjs';
|
|
4
|
+
|
|
5
|
+
// Enterprise WeChat self-built application protocol layer: URL verification and
|
|
6
|
+
// encrypted callbacks share the official WeCom crypto scheme exposed by
|
|
7
|
+
// @wecom/aibot-node-sdk, while outbound messages use the public cgi-bin API.
|
|
8
|
+
|
|
9
|
+
export const WECOM_APP_DEFAULT_API_BASE = 'https://qyapi.weixin.qq.com';
|
|
10
|
+
export const WECOM_APP_TEXT_MAX_BYTES = 2048;
|
|
11
|
+
export const WECOM_APP_STREAM_PLACEHOLDER = () => t('正在思考中…');
|
|
12
|
+
|
|
13
|
+
function cleanString(value) {
|
|
14
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class WecomAppError extends Error {
|
|
22
|
+
constructor(code, message, { providerCode, hint } = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'WecomAppError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.providerCode = providerCode;
|
|
27
|
+
this.hint = hint;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeApiBaseUrl(value) {
|
|
32
|
+
const raw = cleanString(value);
|
|
33
|
+
if (!raw) return WECOM_APP_DEFAULT_API_BASE;
|
|
34
|
+
if (!/^https?:\/\//iu.test(raw)) {
|
|
35
|
+
throw new WecomAppError('invalid-api-base', '代理地址必须是 http(s) 地址');
|
|
36
|
+
}
|
|
37
|
+
return raw.replace(/\/+$/u, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function byteLength(value) {
|
|
41
|
+
return Buffer.byteLength(value, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// WeCom text messages accept at most 2048 UTF-8 bytes per message; split on
|
|
45
|
+
// code-point boundaries so multi-byte characters are never cut in half.
|
|
46
|
+
export function splitUtf8ByBytes(text, maxBytes = WECOM_APP_TEXT_MAX_BYTES) {
|
|
47
|
+
const value = typeof text === 'string' ? text : '';
|
|
48
|
+
if (value === '') return [''];
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let current = '';
|
|
51
|
+
let currentBytes = 0;
|
|
52
|
+
for (const char of value) {
|
|
53
|
+
const size = byteLength(char);
|
|
54
|
+
if (currentBytes + size > maxBytes && current.length > 0) {
|
|
55
|
+
chunks.push(current);
|
|
56
|
+
current = char;
|
|
57
|
+
currentBytes = size;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
current += char;
|
|
61
|
+
currentBytes += size;
|
|
62
|
+
}
|
|
63
|
+
if (current.length > 0) chunks.push(current);
|
|
64
|
+
return chunks;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parses the two XML shapes WeCom uses (CDATA-wrapped and plain values).
|
|
68
|
+
// Enterprise WeChat payloads are small, flat dictionaries, so a scoped regex
|
|
69
|
+
// parser avoids an XML dependency while matching the official samples.
|
|
70
|
+
export function parseWecomAppXmlBody(xml) {
|
|
71
|
+
const result = {};
|
|
72
|
+
const source = typeof xml === 'string' ? xml : '';
|
|
73
|
+
const cdataPattern = /<([\w:-]+)><!\[CDATA\[([\s\S]*?)\]\]><\/\1>/gu;
|
|
74
|
+
let match;
|
|
75
|
+
while ((match = cdataPattern.exec(source)) !== null) {
|
|
76
|
+
result[match[1]] = match[2];
|
|
77
|
+
}
|
|
78
|
+
const simplePattern = /<([\w:-]+)>([^<>]*)<\/\1>/gu;
|
|
79
|
+
while ((match = simplePattern.exec(source)) !== null) {
|
|
80
|
+
if (result[match[1]] === undefined) result[match[1]] = match[2];
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isXmlFormat(raw) {
|
|
86
|
+
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
|
87
|
+
return trimmed.startsWith('<') && trimmed.endsWith('>');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Normalizes the decrypted plaintext into the internal inbound message shape.
|
|
91
|
+
// Accepts both the JSON and XML callback formats and keeps the JSON field
|
|
92
|
+
// names used by the WeCom documentation as canonical.
|
|
93
|
+
export function parseWecomAppPlainMessage(raw) {
|
|
94
|
+
const source = typeof raw === 'string' ? raw.trim() : '';
|
|
95
|
+
if (!source) return null;
|
|
96
|
+
let data;
|
|
97
|
+
if (isXmlFormat(source)) {
|
|
98
|
+
const xml = parseWecomAppXmlBody(source);
|
|
99
|
+
data = {
|
|
100
|
+
msgid: xml.MsgId ?? xml.msgid,
|
|
101
|
+
msgtype: (xml.MsgType ?? xml.msgtype ?? '').toLowerCase(),
|
|
102
|
+
createTime: xml.CreateTime ?? xml.createTime,
|
|
103
|
+
agentId: xml.AgentID ?? xml.agentid,
|
|
104
|
+
from: xml.FromUserName ? { userid: xml.FromUserName } : undefined,
|
|
105
|
+
to: xml.ToUserName ?? undefined,
|
|
106
|
+
text: xml.Content !== undefined ? { content: xml.Content } : undefined,
|
|
107
|
+
image: xml.PicUrl ? { url: xml.PicUrl } : undefined,
|
|
108
|
+
mediaId: xml.MediaId ?? xml.mediaid,
|
|
109
|
+
recognition: xml.Recognition,
|
|
110
|
+
event: (xml.Event ?? xml.event ?? '').toLowerCase() || undefined,
|
|
111
|
+
eventKey: xml.EventKey,
|
|
112
|
+
stream: xml.StreamId ? { id: xml.StreamId } : undefined,
|
|
113
|
+
chatid: xml.ChatId,
|
|
114
|
+
};
|
|
115
|
+
} else {
|
|
116
|
+
try {
|
|
117
|
+
data = JSON.parse(source);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (!isRecord(data)) return null;
|
|
122
|
+
data = {
|
|
123
|
+
msgid: data.msgid ?? data.MsgId,
|
|
124
|
+
msgtype: String(data.msgtype ?? data.MsgType ?? '').toLowerCase(),
|
|
125
|
+
createTime: data.createTime ?? data.CreateTime,
|
|
126
|
+
agentId: data.agentid ?? data.AgentID,
|
|
127
|
+
from: isRecord(data.from) ? data.from : data.FromUserName ? { userid: data.FromUserName } : undefined,
|
|
128
|
+
to: data.to ?? undefined,
|
|
129
|
+
text: isRecord(data.text) ? data.text : undefined,
|
|
130
|
+
image: isRecord(data.image) ? data.image : undefined,
|
|
131
|
+
mediaId: data.mediaid ?? undefined,
|
|
132
|
+
recognition: typeof data.recognition === 'string' ? data.recognition : undefined,
|
|
133
|
+
event: String(data.event ?? data.eventtype ?? '').toLowerCase() || undefined,
|
|
134
|
+
eventKey: data.eventkey ?? undefined,
|
|
135
|
+
stream: isRecord(data.stream) ? data.stream : undefined,
|
|
136
|
+
chatid: typeof data.chatid === 'string' ? data.chatid : undefined,
|
|
137
|
+
voice: isRecord(data.voice) ? data.voice : undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createUserCrypto({ token, encodingAESKey, corpId }) {
|
|
144
|
+
return new WecomCrypto(cleanString(token) ?? '', cleanString(encodingAESKey) ?? '', cleanString(corpId) ?? '');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Builds the encrypted passive-reply body in the same XML or JSON envelope the
|
|
148
|
+
// callback was received with.
|
|
149
|
+
export function buildEncryptedReply({ format, crypto, plaintext, timestamp, nonce }) {
|
|
150
|
+
const plain = typeof plaintext === 'string' ? plaintext : JSON.stringify(plaintext ?? {});
|
|
151
|
+
const { encrypt, signature } = crypto.encrypt(plain, String(timestamp), String(nonce));
|
|
152
|
+
if (String(format ?? '').toLowerCase() === 'json') {
|
|
153
|
+
return JSON.stringify({ encrypt, msgsignature: signature, timestamp: String(timestamp), nonce: String(nonce) });
|
|
154
|
+
}
|
|
155
|
+
return [
|
|
156
|
+
'<xml>',
|
|
157
|
+
`<Encrypt><![CDATA[${encrypt}]]></Encrypt>`,
|
|
158
|
+
`<MsgSignature><![CDATA[${signature}]]></MsgSignature>`,
|
|
159
|
+
`<TimeStamp>${String(timestamp)}</TimeStamp>`,
|
|
160
|
+
`<Nonce><![CDATA[${String(nonce)}]]></Nonce>`,
|
|
161
|
+
'</xml>',
|
|
162
|
+
].join('');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class WecomAppTokenCache {
|
|
166
|
+
#api;
|
|
167
|
+
#value = null;
|
|
168
|
+
#expiresAt = 0;
|
|
169
|
+
#pending = null;
|
|
170
|
+
|
|
171
|
+
constructor(api) {
|
|
172
|
+
this.#api = api;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
invalidate() {
|
|
176
|
+
this.#value = null;
|
|
177
|
+
this.#expiresAt = 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async getToken(signal, { force = false } = {}) {
|
|
181
|
+
if (!force && this.#value && Date.now() < this.#expiresAt) return this.#value;
|
|
182
|
+
if (this.#pending) return this.#pending;
|
|
183
|
+
this.#pending = this.#fetchToken(signal).finally(() => {
|
|
184
|
+
this.#pending = null;
|
|
185
|
+
});
|
|
186
|
+
return this.#pending;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async #fetchToken(signal) {
|
|
190
|
+
const payload = await this.#api.request('/cgi-bin/gettoken', {
|
|
191
|
+
query: { corpid: this.#api.corpId, corpsecret: this.#api.corpSecret },
|
|
192
|
+
signal,
|
|
193
|
+
});
|
|
194
|
+
if (!payload.access_token) {
|
|
195
|
+
throw new WecomAppError('token-missing', '企业微信没有返回 access_token', {
|
|
196
|
+
providerCode: payload.errcode === undefined ? undefined : String(payload.errcode),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
this.#value = payload.access_token;
|
|
200
|
+
const lifetime = Number(payload.expires_in);
|
|
201
|
+
this.#expiresAt = Date.now() + (Number.isFinite(lifetime) && lifetime > 300 ? lifetime - 300 : 3_600) * 1_000;
|
|
202
|
+
return this.#value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class WecomAppApi {
|
|
207
|
+
#base;
|
|
208
|
+
#tokens;
|
|
209
|
+
#logger;
|
|
210
|
+
#fetchImpl;
|
|
211
|
+
#timeoutMs;
|
|
212
|
+
|
|
213
|
+
constructor({ corpId, corpSecret, agentId, apiBaseUrl, logger = console, fetchImpl, timeoutMs = 15_000 }) {
|
|
214
|
+
if (!cleanString(corpId) || !cleanString(corpSecret) || !cleanString(agentId)) {
|
|
215
|
+
throw new TypeError('WecomAppApi requires corpId, corpSecret, and agentId');
|
|
216
|
+
}
|
|
217
|
+
this.corpId = cleanString(corpId);
|
|
218
|
+
this.corpSecret = cleanString(corpSecret);
|
|
219
|
+
this.agentId = cleanString(agentId);
|
|
220
|
+
this.#base = normalizeApiBaseUrl(apiBaseUrl);
|
|
221
|
+
this.#logger = logger;
|
|
222
|
+
this.#fetchImpl = fetchImpl ?? fetch;
|
|
223
|
+
this.#timeoutMs = timeoutMs;
|
|
224
|
+
this.#tokens = new WecomAppTokenCache(this);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
get apiBaseUrl() {
|
|
228
|
+
return this.#base;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async #fetchJson(url, { method = 'GET', body, headers, signal, timeoutMs } = {}) {
|
|
232
|
+
const controller = new AbortController();
|
|
233
|
+
const abort = () => controller.abort(new DOMException('WecomApp API request aborted', 'AbortError'));
|
|
234
|
+
const timer = setTimeout(() => controller.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs ?? this.#timeoutMs);
|
|
235
|
+
if (signal) {
|
|
236
|
+
if (signal.aborted) abort();
|
|
237
|
+
else signal.addEventListener('abort', abort, { once: true });
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const response = await this.#fetchImpl(url, {
|
|
241
|
+
method,
|
|
242
|
+
body,
|
|
243
|
+
headers,
|
|
244
|
+
signal: controller.signal,
|
|
245
|
+
});
|
|
246
|
+
const text = await response.text();
|
|
247
|
+
let payload;
|
|
248
|
+
try {
|
|
249
|
+
payload = text ? JSON.parse(text) : {};
|
|
250
|
+
} catch {
|
|
251
|
+
throw new WecomAppError('bad-response', `企业微信返回了无法解析的内容(HTTP ${response.status})`);
|
|
252
|
+
}
|
|
253
|
+
return { status: response.status, payload };
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof WecomAppError) throw error;
|
|
256
|
+
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
|
257
|
+
throw new WecomAppError('api-timeout', '企业微信 API 请求超时或被取消');
|
|
258
|
+
}
|
|
259
|
+
throw new WecomAppError('network-failed', `企业微信 API 网络请求失败:${error?.message ?? String(error)}`);
|
|
260
|
+
} finally {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
signal?.removeEventListener?.('abort', abort);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async request(pathname, { query, signal, retryOnToken = true } = {}) {
|
|
267
|
+
const url = new URL(`${this.#base}${pathname}`);
|
|
268
|
+
// URLSearchParams instances carry no enumerable own properties, so
|
|
269
|
+
// Object.entries() would silently drop every parameter.
|
|
270
|
+
const pairs = typeof query?.entries === 'function' ? [...query.entries()] : Object.entries(query ?? {});
|
|
271
|
+
for (const [key, value] of pairs) {
|
|
272
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
273
|
+
}
|
|
274
|
+
const { payload } = await this.#fetchJson(url, { signal });
|
|
275
|
+
const errcode = Number(payload.errcode ?? 0);
|
|
276
|
+
if (errcode !== 0) {
|
|
277
|
+
if (retryOnToken && (errcode === 40014 || errcode === 42001)) {
|
|
278
|
+
this.#tokens.invalidate();
|
|
279
|
+
return this.request(pathname, { query, signal, retryOnToken: false });
|
|
280
|
+
}
|
|
281
|
+
throw this.#apiError(errcode, payload.errmsg);
|
|
282
|
+
}
|
|
283
|
+
return payload;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#apiError(errcode, errmsg) {
|
|
287
|
+
const code = String(errcode);
|
|
288
|
+
const message = `企业微信 API 调用失败(${code}:${errmsg ?? 'unknown'})`;
|
|
289
|
+
if (errcode === 60020) return new WecomAppError('trusted-ip', message, {
|
|
290
|
+
providerCode: code,
|
|
291
|
+
hint: '请在企业微信后台将该服务器公网 IP 加入“企业可信 IP”',
|
|
292
|
+
});
|
|
293
|
+
if (errcode === 81013) return new WecomAppError('invalid-user', message, {
|
|
294
|
+
providerCode: code,
|
|
295
|
+
hint: 'userid、部门或标签不存在,请检查应用可见范围',
|
|
296
|
+
});
|
|
297
|
+
if (errcode === 40056) return new WecomAppError('invalid-agent', message, {
|
|
298
|
+
providerCode: code,
|
|
299
|
+
hint: 'agentid 不合法,请核对自建应用 AgentId',
|
|
300
|
+
});
|
|
301
|
+
return new WecomAppError('api-failed', message, { providerCode: code });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async #send({ payload, signal }) {
|
|
305
|
+
const token = await this.#tokens.getToken(signal);
|
|
306
|
+
const url = new URL(`${this.#base}/cgi-bin/message/send`);
|
|
307
|
+
url.searchParams.set('access_token', token);
|
|
308
|
+
const { payload: result } = await this.#fetchJson(url, {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
body: JSON.stringify({ ...payload, agentid: Number(this.agentId) }),
|
|
311
|
+
headers: { 'content-type': 'application/json' },
|
|
312
|
+
signal,
|
|
313
|
+
});
|
|
314
|
+
const errcode = Number(result.errcode ?? 0);
|
|
315
|
+
if (errcode !== 0) throw this.#apiError(errcode, result.errmsg);
|
|
316
|
+
if (Array.isArray(result?.invaliduser) && result.invaliduser.length > 0) {
|
|
317
|
+
throw new WecomAppError('invalid-user', `消息没有送达:${result.invaliduser.join(', ')}`, {
|
|
318
|
+
providerCode: '81013',
|
|
319
|
+
hint: '请确认接收人仍在应用可见范围内',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async sendText({ userId, content, signal } = {}) {
|
|
326
|
+
const target = cleanString(userId);
|
|
327
|
+
const text = typeof content === 'string' ? content : '';
|
|
328
|
+
if (!target || !text) throw new WecomAppError('bad-request', 'sendText requires userId and content');
|
|
329
|
+
return this.#send({
|
|
330
|
+
payload: { touser: target, msgtype: 'text', text: { content: text } },
|
|
331
|
+
signal,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async sendMedia({ userId, mediaType, mediaId, signal } = {}) {
|
|
336
|
+
const target = cleanString(userId);
|
|
337
|
+
if (!target || !cleanString(mediaId)) throw new WecomAppError('bad-request', 'sendMedia requires userId and mediaId');
|
|
338
|
+
return this.#send({
|
|
339
|
+
payload: { touser: target, msgtype: mediaType, [mediaType]: { media_id: cleanString(mediaId) } },
|
|
340
|
+
signal,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async uploadMedia({ type = 'image', bytes, filename, signal } = {}) {
|
|
345
|
+
const mediaType = type === 'file' ? 'file' : 'image';
|
|
346
|
+
if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
|
|
347
|
+
throw new WecomAppError('bad-request', 'uploadMedia requires non-empty bytes');
|
|
348
|
+
}
|
|
349
|
+
const token = await this.#tokens.getToken(signal);
|
|
350
|
+
const url = new URL(`${this.#base}/cgi-bin/media/upload`);
|
|
351
|
+
url.searchParams.set('access_token', token);
|
|
352
|
+
url.searchParams.set('type', mediaType);
|
|
353
|
+
const form = new FormData();
|
|
354
|
+
form.append('media', new Blob([bytes]), filename ?? (mediaType === 'image' ? 'image.png' : 'file.bin'));
|
|
355
|
+
const { payload: result } = await this.#fetchJson(url, {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
body: form,
|
|
358
|
+
signal,
|
|
359
|
+
timeoutMs: 60_000,
|
|
360
|
+
});
|
|
361
|
+
if (!result.media_id) {
|
|
362
|
+
throw this.#apiError(Number(result.errcode ?? -1), result.errmsg);
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async health(signal) {
|
|
368
|
+
await this.#tokens.getToken(signal);
|
|
369
|
+
return { ok: true };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async #rawFetch(url, { signal, timeoutMs } = {}) {
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
const abort = () => controller.abort(new DOMException('WecomApp API request aborted', 'AbortError'));
|
|
375
|
+
const timer = setTimeout(() => controller.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs ?? this.#timeoutMs);
|
|
376
|
+
if (signal) {
|
|
377
|
+
if (signal.aborted) abort();
|
|
378
|
+
else signal.addEventListener('abort', abort, { once: true });
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
return await this.#fetchImpl(url, { method: 'GET', signal: controller.signal });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
|
384
|
+
throw new WecomAppError('api-timeout', '企业微信媒体下载超时或被取消');
|
|
385
|
+
}
|
|
386
|
+
throw new WecomAppError('network-failed', `企业微信媒体下载失败:${error?.message ?? String(error)}`);
|
|
387
|
+
} finally {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
signal?.removeEventListener?.('abort', abort);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Downloads media through the authenticated media/get API. WeCom answers
|
|
394
|
+
// with raw bytes on success and a JSON error document on failure.
|
|
395
|
+
async downloadMedia({ mediaId, signal } = {}) {
|
|
396
|
+
const target = cleanString(mediaId);
|
|
397
|
+
if (!target) throw new WecomAppError('bad-request', 'downloadMedia requires mediaId');
|
|
398
|
+
const token = await this.#tokens.getToken(signal);
|
|
399
|
+
const url = new URL(`${this.#base}/cgi-bin/media/get`);
|
|
400
|
+
url.searchParams.set('access_token', token);
|
|
401
|
+
url.searchParams.set('media_id', target);
|
|
402
|
+
const response = await this.#rawFetch(url, { signal, timeoutMs: 60_000 });
|
|
403
|
+
const contentType = String(response.headers.get('content-type') ?? '');
|
|
404
|
+
if (contentType.includes('application/json')) {
|
|
405
|
+
const text = await response.text();
|
|
406
|
+
let payload = {};
|
|
407
|
+
try {
|
|
408
|
+
payload = JSON.parse(text);
|
|
409
|
+
} catch {
|
|
410
|
+
payload = {};
|
|
411
|
+
}
|
|
412
|
+
throw this.#apiError(Number(payload.errcode ?? -1), payload.errmsg);
|
|
413
|
+
}
|
|
414
|
+
const data = Buffer.from(await response.arrayBuffer());
|
|
415
|
+
const disposition = String(response.headers.get('content-disposition') ?? '');
|
|
416
|
+
const filename = disposition.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/iu)?.[1] ?? undefined;
|
|
417
|
+
return { data, filename, contentType };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async sendArtifactFile({ userId, kind = 'image', bytes, filename, signal } = {}) {
|
|
421
|
+
const target = cleanString(userId);
|
|
422
|
+
const data = bytes instanceof Uint8Array ? bytes : null;
|
|
423
|
+
if (!target || !data || data.length === 0 || !cleanString(filename)) {
|
|
424
|
+
throw new WecomAppError('bad-request', 'sendArtifactFile requires userId, bytes, and filename');
|
|
425
|
+
}
|
|
426
|
+
const mediaType = kind === 'file' ? 'file' : 'image';
|
|
427
|
+
const upload = await this.uploadMedia({ type: mediaType, bytes: data, filename: cleanString(filename), signal });
|
|
428
|
+
return this.sendMedia({ userId: target, mediaType, mediaId: upload.media_id, signal });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export { cleanString };
|