@kin-tio/cli 0.6.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/.env.example +46 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +202 -0
- package/README.md +150 -0
- package/README.zh-CN.md +79 -0
- package/THIRD_PARTY_NOTICES +31 -0
- package/assets/ilink-login-card.png +0 -0
- package/bin/kintio.js +3 -0
- package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
- package/dist/cli.js +3 -0
- package/dist/daemon.js +28 -0
- package/dist/index.js +70 -0
- package/dist/mcp-relay.js +11 -0
- package/dist/src/agent/runtime.js +1 -0
- package/dist/src/app.js +34 -0
- package/dist/src/cli.js +578 -0
- package/dist/src/config.js +237 -0
- package/dist/src/domain/message.js +23 -0
- package/dist/src/domain/send-contract.js +205 -0
- package/dist/src/domain/wecom-message.js +281 -0
- package/dist/src/ilink/executor.js +306 -0
- package/dist/src/ilink/inbound-image.js +310 -0
- package/dist/src/ilink/listener.js +306 -0
- package/dist/src/ilink/login-manager.js +198 -0
- package/dist/src/ilink/login-store.js +197 -0
- package/dist/src/ilink/media-gateway.js +83 -0
- package/dist/src/ilink/media.js +267 -0
- package/dist/src/ilink/message.js +247 -0
- package/dist/src/ilink/protocol/client.js +464 -0
- package/dist/src/ilink/protocol/types.js +35 -0
- package/dist/src/ilink/qr.js +109 -0
- package/dist/src/ilink/secret-box.js +143 -0
- package/dist/src/ilink/sqlite-store.js +1194 -0
- package/dist/src/ilink/store-types.js +63 -0
- package/dist/src/lib/image-format.js +23 -0
- package/dist/src/lib/path-identity.js +38 -0
- package/dist/src/lib/private-directory.js +51 -0
- package/dist/src/lib/text.js +19 -0
- package/dist/src/lib/wecom-crypto.js +74 -0
- package/dist/src/lib/xml.js +8 -0
- package/dist/src/mcp/conversation-memory-server.js +179 -0
- package/dist/src/mcp/ilink-server.js +158 -0
- package/dist/src/mcp/ipc-host.js +275 -0
- package/dist/src/mcp/ipc-protocol.js +226 -0
- package/dist/src/mcp/stdio-relay.js +122 -0
- package/dist/src/mcp/wechat-kf-executor.js +295 -0
- package/dist/src/mcp/wechat-kf-server.js +208 -0
- package/dist/src/routes/wecom.js +89 -0
- package/dist/src/runtime/daemon-protocol.js +202 -0
- package/dist/src/runtime/managed-skill.js +49 -0
- package/dist/src/runtime/native-daemon.js +325 -0
- package/dist/src/runtime/single-instance-lock.js +167 -0
- package/dist/src/runtime.js +503 -0
- package/dist/src/services/codex-agent.js +542 -0
- package/dist/src/services/codex-app-server.js +436 -0
- package/dist/src/services/conversation-processor.js +762 -0
- package/dist/src/services/image-stager.js +49 -0
- package/dist/src/services/media-gateway.js +83 -0
- package/dist/src/services/wecom-api.js +311 -0
- package/dist/src/services/wecom-sync.js +316 -0
- package/dist/src/state/persistence.js +124 -0
- package/dist/src/state/sqlite-store.js +3102 -0
- package/dist/src/supervisor.js +212 -0
- package/dist/src/types.js +1 -0
- package/dist/src/version.js +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { readdirSync, rmSync, } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { MAX_WECHAT_IMAGE_BYTES, detectImageFormat, } from '../lib/image-format.js';
|
|
6
|
+
import { ensurePrivateDirectory } from '../lib/private-directory.js';
|
|
7
|
+
const STAGED_IMAGE_PREFIX = 'kintio-image-';
|
|
8
|
+
const LEGACY_STAGED_IMAGE_PREFIXES = [
|
|
9
|
+
'talkferry-image-',
|
|
10
|
+
'wechat-codex-image-',
|
|
11
|
+
];
|
|
12
|
+
export function cleanupStagedImageOrphans(temporaryRoot) {
|
|
13
|
+
ensurePrivateDirectory(temporaryRoot);
|
|
14
|
+
for (const entry of readdirSync(temporaryRoot, { withFileTypes: true })) {
|
|
15
|
+
if (!entry.name.startsWith(STAGED_IMAGE_PREFIX) &&
|
|
16
|
+
!LEGACY_STAGED_IMAGE_PREFIXES.some((prefix) => entry.name.startsWith(prefix)))
|
|
17
|
+
continue;
|
|
18
|
+
rmSync(path.join(temporaryRoot, entry.name), { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function withStagedImages(images, { temporaryRoot = os.tmpdir() } = {}, operation) {
|
|
22
|
+
if (images.length === 0) {
|
|
23
|
+
return operation([]);
|
|
24
|
+
}
|
|
25
|
+
const temporaryDirectory = await fs.mkdtemp(path.join(temporaryRoot, STAGED_IMAGE_PREFIX));
|
|
26
|
+
await fs.chmod(temporaryDirectory, 0o700);
|
|
27
|
+
try {
|
|
28
|
+
const paths = [];
|
|
29
|
+
for (const [index, image] of images.entries()) {
|
|
30
|
+
if (!Buffer.isBuffer(image.bytes) || image.bytes.length === 0) {
|
|
31
|
+
throw new Error('Downloaded image is empty');
|
|
32
|
+
}
|
|
33
|
+
if (image.bytes.length > MAX_WECHAT_IMAGE_BYTES) {
|
|
34
|
+
throw new Error('Downloaded image exceeds the 2 MiB WeChat limit');
|
|
35
|
+
}
|
|
36
|
+
const format = detectImageFormat(image.bytes);
|
|
37
|
+
if (!format) {
|
|
38
|
+
throw new Error('Downloaded media is not a supported image format');
|
|
39
|
+
}
|
|
40
|
+
const imagePath = path.join(temporaryDirectory, `image-${index}${format.extension}`);
|
|
41
|
+
await fs.writeFile(imagePath, image.bytes, { mode: 0o600 });
|
|
42
|
+
paths.push(imagePath);
|
|
43
|
+
}
|
|
44
|
+
return await operation(paths);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const DEFAULT_LINK_THUMBNAIL = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlXcAAAAASUVORK5CYII=', 'base64');
|
|
2
|
+
const LINK_THUMBNAIL_CACHE_MS = 60 * 60 * 1000;
|
|
3
|
+
const OUTBOUND_MEDIA_CACHE_MS = 60 * 60 * 1000;
|
|
4
|
+
const IMAGE_KIND = 'image';
|
|
5
|
+
function defaultFilename(kind, contentType) {
|
|
6
|
+
if (kind === IMAGE_KIND) {
|
|
7
|
+
return contentType === 'image/jpeg' ? 'image.jpg' : 'image.png';
|
|
8
|
+
}
|
|
9
|
+
throw new Error(`Unsupported outbound attachment kind: ${kind}`);
|
|
10
|
+
}
|
|
11
|
+
export class WecomMediaGateway {
|
|
12
|
+
apiClient;
|
|
13
|
+
cardThumbnailCache;
|
|
14
|
+
outboundMediaCache;
|
|
15
|
+
constructor({ apiClient }) {
|
|
16
|
+
this.apiClient = apiClient;
|
|
17
|
+
this.cardThumbnailCache = null;
|
|
18
|
+
this.outboundMediaCache = new Map();
|
|
19
|
+
}
|
|
20
|
+
async resolveForCodex(message) {
|
|
21
|
+
const resolved = [];
|
|
22
|
+
for (const attachment of message.attachments || []) {
|
|
23
|
+
if (attachment.kind !== IMAGE_KIND)
|
|
24
|
+
continue;
|
|
25
|
+
const media = await this.apiClient.downloadMedia(attachment.mediaId);
|
|
26
|
+
resolved.push({
|
|
27
|
+
kind: IMAGE_KIND,
|
|
28
|
+
bytes: media.bytes,
|
|
29
|
+
contentType: media.contentType,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
async upload({ kind, bytes, filename, contentType, }) {
|
|
35
|
+
if (kind !== IMAGE_KIND) {
|
|
36
|
+
throw new Error(`Unsupported attachment kind: ${kind}`);
|
|
37
|
+
}
|
|
38
|
+
return this.apiClient.uploadMedia({
|
|
39
|
+
type: 'image',
|
|
40
|
+
bytes,
|
|
41
|
+
filename,
|
|
42
|
+
contentType,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async cloneForSend({ kind, sourceMediaId, filename = '', }) {
|
|
46
|
+
const cacheKey = `${kind}:${sourceMediaId}`;
|
|
47
|
+
const cached = this.outboundMediaCache.get(cacheKey);
|
|
48
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
49
|
+
return cached.mediaId;
|
|
50
|
+
}
|
|
51
|
+
const source = await this.apiClient.downloadMedia(sourceMediaId);
|
|
52
|
+
const uploaded = await this.upload({
|
|
53
|
+
kind,
|
|
54
|
+
bytes: source.bytes,
|
|
55
|
+
filename: filename ||
|
|
56
|
+
source.filename ||
|
|
57
|
+
defaultFilename(kind, source.contentType),
|
|
58
|
+
contentType: source.contentType,
|
|
59
|
+
});
|
|
60
|
+
this.outboundMediaCache.set(cacheKey, {
|
|
61
|
+
mediaId: uploaded.media_id,
|
|
62
|
+
expiresAt: Date.now() + OUTBOUND_MEDIA_CACHE_MS,
|
|
63
|
+
});
|
|
64
|
+
return uploaded.media_id;
|
|
65
|
+
}
|
|
66
|
+
async getCardThumbnailMediaId() {
|
|
67
|
+
const cached = this.cardThumbnailCache;
|
|
68
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
69
|
+
return cached.mediaId;
|
|
70
|
+
}
|
|
71
|
+
const result = await this.upload({
|
|
72
|
+
kind: IMAGE_KIND,
|
|
73
|
+
bytes: DEFAULT_LINK_THUMBNAIL,
|
|
74
|
+
filename: 'link-thumbnail.png',
|
|
75
|
+
contentType: 'image/png',
|
|
76
|
+
});
|
|
77
|
+
this.cardThumbnailCache = {
|
|
78
|
+
mediaId: result.media_id,
|
|
79
|
+
expiresAt: Date.now() + LINK_THUMBNAIL_CACHE_MS,
|
|
80
|
+
};
|
|
81
|
+
return this.cardThumbnailCache.mediaId;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
const DEFAULT_BASE_URL = 'https://qyapi.weixin.qq.com';
|
|
3
|
+
const INVALID_ACCESS_TOKEN_CODES = new Set([40014, 42001]);
|
|
4
|
+
const MAX_MEDIA_ID_CHARACTERS = 512;
|
|
5
|
+
const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
const MEDIA_LIMITS = Object.freeze({
|
|
7
|
+
image: 2 * 1024 * 1024,
|
|
8
|
+
});
|
|
9
|
+
const SEND_MESSAGE_TYPES = new Set([
|
|
10
|
+
'text',
|
|
11
|
+
'image',
|
|
12
|
+
'link',
|
|
13
|
+
'miniprogram',
|
|
14
|
+
'location',
|
|
15
|
+
]);
|
|
16
|
+
function errorMessage(error) {
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
19
|
+
function withClientMessageId(body, messageId) {
|
|
20
|
+
const value = String(messageId || '');
|
|
21
|
+
if (!value)
|
|
22
|
+
return body;
|
|
23
|
+
if (value.length > 32 || !/^[0-9A-Za-z_-]+$/.test(value)) {
|
|
24
|
+
throw new Error('WeCom client messageId must use 1 to 32 letters, digits, underscores, or hyphens');
|
|
25
|
+
}
|
|
26
|
+
return { ...body, msgid: value };
|
|
27
|
+
}
|
|
28
|
+
function safeFilename(filename) {
|
|
29
|
+
return String(filename || 'media.bin')
|
|
30
|
+
.replace(/[\r\n"\\/]/g, '_')
|
|
31
|
+
.slice(0, 128);
|
|
32
|
+
}
|
|
33
|
+
function responseFilename(contentDisposition) {
|
|
34
|
+
const value = String(contentDisposition || '');
|
|
35
|
+
const encoded = value.match(/filename\*\s*=\s*UTF-8''([^;]+)/i)?.[1];
|
|
36
|
+
if (encoded) {
|
|
37
|
+
try {
|
|
38
|
+
return safeFilename(decodeURIComponent(encoded));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return safeFilename(encoded);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const plain = value.match(/filename\s*=\s*"([^"]+)"/i)?.[1];
|
|
45
|
+
return plain ? safeFilename(plain) : '';
|
|
46
|
+
}
|
|
47
|
+
function createMultipartBody({ bytes, filename, contentType, }) {
|
|
48
|
+
const boundary = `----kintio-${crypto.randomUUID()}`;
|
|
49
|
+
const header = Buffer.from([
|
|
50
|
+
`--${boundary}`,
|
|
51
|
+
`Content-Disposition: form-data; name="media"; filename="${safeFilename(filename)}"; filelength=${bytes.length}`,
|
|
52
|
+
`Content-Type: ${contentType || 'application/octet-stream'}`,
|
|
53
|
+
'',
|
|
54
|
+
'',
|
|
55
|
+
].join('\r\n'));
|
|
56
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
57
|
+
return {
|
|
58
|
+
body: Buffer.concat([header, bytes, footer]),
|
|
59
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function readLimitedBody(response, limit = MAX_DOWNLOAD_BYTES) {
|
|
63
|
+
const declaredLength = Number(response.headers.get('content-length') || 0);
|
|
64
|
+
if (declaredLength > limit) {
|
|
65
|
+
throw new WecomApiError(`media/get response exceeds ${limit} bytes`);
|
|
66
|
+
}
|
|
67
|
+
if (!response.body?.getReader) {
|
|
68
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
69
|
+
if (bytes.length > limit) {
|
|
70
|
+
throw new WecomApiError(`media/get response exceeds ${limit} bytes`);
|
|
71
|
+
}
|
|
72
|
+
return bytes;
|
|
73
|
+
}
|
|
74
|
+
const reader = response.body.getReader();
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let size = 0;
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
const { done, value } = await reader.read();
|
|
80
|
+
if (done)
|
|
81
|
+
break;
|
|
82
|
+
size += value.byteLength;
|
|
83
|
+
if (size > limit) {
|
|
84
|
+
await reader.cancel();
|
|
85
|
+
throw new WecomApiError(`media/get response exceeds ${limit} bytes`);
|
|
86
|
+
}
|
|
87
|
+
chunks.push(Buffer.from(value));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
reader.releaseLock();
|
|
92
|
+
}
|
|
93
|
+
return Buffer.concat(chunks, size);
|
|
94
|
+
}
|
|
95
|
+
export class WecomApiError extends Error {
|
|
96
|
+
code;
|
|
97
|
+
data;
|
|
98
|
+
constructor(message, { code, data } = {}) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = 'WecomApiError';
|
|
101
|
+
this.code = code;
|
|
102
|
+
this.data = data;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export class WecomApiClient {
|
|
106
|
+
corpId;
|
|
107
|
+
kfSecret;
|
|
108
|
+
fetch;
|
|
109
|
+
baseUrl;
|
|
110
|
+
timeoutMs;
|
|
111
|
+
tokenCache = null;
|
|
112
|
+
pendingTokenRequest = null;
|
|
113
|
+
constructor({ corpId, kfSecret, fetchImpl = globalThis.fetch, baseUrl = DEFAULT_BASE_URL, timeoutMs = 10_000, }) {
|
|
114
|
+
if (!corpId || !kfSecret) {
|
|
115
|
+
throw new Error('WeCom CorpID and WeChat KF Secret are required');
|
|
116
|
+
}
|
|
117
|
+
if (typeof fetchImpl !== 'function') {
|
|
118
|
+
throw new Error('A fetch implementation is required');
|
|
119
|
+
}
|
|
120
|
+
this.corpId = corpId;
|
|
121
|
+
this.kfSecret = kfSecret;
|
|
122
|
+
this.fetch = fetchImpl;
|
|
123
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
124
|
+
this.timeoutMs = timeoutMs;
|
|
125
|
+
}
|
|
126
|
+
clearAccessToken() {
|
|
127
|
+
this.tokenCache = null;
|
|
128
|
+
}
|
|
129
|
+
async getAccessToken() {
|
|
130
|
+
const cached = this.tokenCache;
|
|
131
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
132
|
+
return cached.value;
|
|
133
|
+
}
|
|
134
|
+
if (this.pendingTokenRequest) {
|
|
135
|
+
return this.pendingTokenRequest;
|
|
136
|
+
}
|
|
137
|
+
this.pendingTokenRequest = this.#requestAccessToken().finally(() => {
|
|
138
|
+
this.pendingTokenRequest = null;
|
|
139
|
+
});
|
|
140
|
+
return this.pendingTokenRequest;
|
|
141
|
+
}
|
|
142
|
+
async #requestAccessToken() {
|
|
143
|
+
const query = new URLSearchParams({
|
|
144
|
+
corpid: this.corpId,
|
|
145
|
+
corpsecret: this.kfSecret,
|
|
146
|
+
});
|
|
147
|
+
const data = await this.#fetchJson(`${this.baseUrl}/cgi-bin/gettoken?${query}`, { method: 'GET' }, 'gettoken');
|
|
148
|
+
const errorCode = Number(data.errcode || 0);
|
|
149
|
+
if (errorCode !== 0) {
|
|
150
|
+
throw new WecomApiError(`gettoken failed: ${errorCode} ${data.errmsg || ''}`.trim(), { code: errorCode, data });
|
|
151
|
+
}
|
|
152
|
+
if (!data.access_token || !Number.isFinite(Number(data.expires_in))) {
|
|
153
|
+
throw new WecomApiError('gettoken returned an invalid response', { data });
|
|
154
|
+
}
|
|
155
|
+
const expiresIn = Math.max(60, Number(data.expires_in) - 300);
|
|
156
|
+
this.tokenCache = {
|
|
157
|
+
value: String(data.access_token),
|
|
158
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
159
|
+
};
|
|
160
|
+
return this.tokenCache.value;
|
|
161
|
+
}
|
|
162
|
+
async #fetchJson(url, options, operation) {
|
|
163
|
+
let response;
|
|
164
|
+
try {
|
|
165
|
+
response = await this.fetch(url, {
|
|
166
|
+
...options,
|
|
167
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw new WecomApiError(`${operation} request failed: ${errorMessage(error)}`);
|
|
172
|
+
}
|
|
173
|
+
const responseText = await response.text();
|
|
174
|
+
let data;
|
|
175
|
+
try {
|
|
176
|
+
data = JSON.parse(responseText);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new WecomApiError(`${operation} returned non-JSON HTTP ${response.status}`);
|
|
180
|
+
}
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
throw new WecomApiError(`${operation} returned HTTP ${response.status}`, { data });
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
async #postApi(path, body, retryAccessToken = true) {
|
|
187
|
+
const accessToken = await this.getAccessToken();
|
|
188
|
+
const query = new URLSearchParams({ access_token: accessToken });
|
|
189
|
+
const data = await this.#fetchJson(`${this.baseUrl}${path}?${query}`, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
192
|
+
body: JSON.stringify(body),
|
|
193
|
+
}, path);
|
|
194
|
+
const errorCode = Number(data.errcode || 0);
|
|
195
|
+
if (retryAccessToken && INVALID_ACCESS_TOKEN_CODES.has(errorCode)) {
|
|
196
|
+
this.clearAccessToken();
|
|
197
|
+
return this.#postApi(path, body, false);
|
|
198
|
+
}
|
|
199
|
+
if (errorCode !== 0) {
|
|
200
|
+
throw new WecomApiError(`${path} failed: ${errorCode} ${data.errmsg || ''}`.trim(), { code: errorCode, data });
|
|
201
|
+
}
|
|
202
|
+
return data;
|
|
203
|
+
}
|
|
204
|
+
async #downloadMedia(mediaId, retryAccessToken = true) {
|
|
205
|
+
const accessToken = await this.getAccessToken();
|
|
206
|
+
const query = new URLSearchParams({
|
|
207
|
+
access_token: accessToken,
|
|
208
|
+
media_id: mediaId,
|
|
209
|
+
});
|
|
210
|
+
let response;
|
|
211
|
+
try {
|
|
212
|
+
response = await this.fetch(`${this.baseUrl}/cgi-bin/media/get?${query}`, { method: 'GET', signal: AbortSignal.timeout(this.timeoutMs) });
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
throw new WecomApiError(`media/get request failed: ${errorMessage(error)}`);
|
|
216
|
+
}
|
|
217
|
+
const contentType = (String(response.headers.get('content-type') || '')
|
|
218
|
+
.split(';')[0] ?? '')
|
|
219
|
+
.trim()
|
|
220
|
+
.toLowerCase();
|
|
221
|
+
if (contentType === 'application/json' || contentType === 'text/json') {
|
|
222
|
+
const data = (await response.json());
|
|
223
|
+
const errorCode = Number(data.errcode || 0);
|
|
224
|
+
if (retryAccessToken && INVALID_ACCESS_TOKEN_CODES.has(errorCode)) {
|
|
225
|
+
this.clearAccessToken();
|
|
226
|
+
return this.#downloadMedia(mediaId, false);
|
|
227
|
+
}
|
|
228
|
+
throw new WecomApiError(`media/get failed: ${errorCode} ${data.errmsg || ''}`.trim(), { code: errorCode, data });
|
|
229
|
+
}
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new WecomApiError(`media/get returned HTTP ${response.status}`);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
bytes: await readLimitedBody(response),
|
|
235
|
+
contentType,
|
|
236
|
+
filename: responseFilename(response.headers.get('content-disposition')),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async #uploadMedia({ type, bytes, filename, contentType }, retryAccessToken = true) {
|
|
240
|
+
const accessToken = await this.getAccessToken();
|
|
241
|
+
const query = new URLSearchParams({ access_token: accessToken, type });
|
|
242
|
+
const multipart = createMultipartBody({ bytes, filename, contentType });
|
|
243
|
+
const data = await this.#fetchJson(`${this.baseUrl}/cgi-bin/media/upload?${query}`, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: {
|
|
246
|
+
'Content-Type': multipart.contentType,
|
|
247
|
+
'Content-Length': String(multipart.body.length),
|
|
248
|
+
},
|
|
249
|
+
body: multipart.body,
|
|
250
|
+
}, 'media/upload');
|
|
251
|
+
const errorCode = Number(data.errcode || 0);
|
|
252
|
+
if (retryAccessToken && INVALID_ACCESS_TOKEN_CODES.has(errorCode)) {
|
|
253
|
+
this.clearAccessToken();
|
|
254
|
+
return this.#uploadMedia({ type, bytes, filename, contentType }, false);
|
|
255
|
+
}
|
|
256
|
+
if (errorCode !== 0 || !data.media_id) {
|
|
257
|
+
throw new WecomApiError(`media/upload failed: ${errorCode} ${data.errmsg || ''}`.trim(), { code: errorCode, data });
|
|
258
|
+
}
|
|
259
|
+
return data;
|
|
260
|
+
}
|
|
261
|
+
async syncMessages({ cursor, callbackToken, openKfId, limit = 1000, voiceFormat = 0, }) {
|
|
262
|
+
const body = { limit, voice_format: voiceFormat };
|
|
263
|
+
if (cursor)
|
|
264
|
+
body.cursor = cursor;
|
|
265
|
+
if (callbackToken)
|
|
266
|
+
body.token = callbackToken;
|
|
267
|
+
if (openKfId)
|
|
268
|
+
body.open_kfid = openKfId;
|
|
269
|
+
const data = await this.#postApi('/cgi-bin/kf/sync_msg', body);
|
|
270
|
+
return {
|
|
271
|
+
...data,
|
|
272
|
+
has_more: Number(data.has_more || 0),
|
|
273
|
+
msg_list: Array.isArray(data.msg_list)
|
|
274
|
+
? data.msg_list
|
|
275
|
+
: [],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
async sendPreparedMessage({ toUser, openKfId, payload, messageId = '', }) {
|
|
279
|
+
if (!toUser || !openKfId) {
|
|
280
|
+
throw new Error('toUser and openKfId are required');
|
|
281
|
+
}
|
|
282
|
+
const msgtype = String(payload?.msgtype || '');
|
|
283
|
+
if (!SEND_MESSAGE_TYPES.has(msgtype) || !payload?.[msgtype]) {
|
|
284
|
+
throw new Error('Prepared WeCom payload has an unsupported message type');
|
|
285
|
+
}
|
|
286
|
+
const exactPayload = structuredClone(payload);
|
|
287
|
+
for (const forbidden of ['touser', 'open_kfid', 'msgid']) {
|
|
288
|
+
if (Object.hasOwn(exactPayload, forbidden)) {
|
|
289
|
+
throw new Error(`Prepared WeCom payload must not contain ${forbidden}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return this.#postApi('/cgi-bin/kf/send_msg', withClientMessageId({ touser: toUser, open_kfid: openKfId, ...exactPayload }, messageId));
|
|
293
|
+
}
|
|
294
|
+
async downloadMedia(mediaId) {
|
|
295
|
+
const value = String(mediaId || '');
|
|
296
|
+
if (!value || value.length > MAX_MEDIA_ID_CHARACTERS) {
|
|
297
|
+
throw new Error('mediaId must contain 1 to 512 characters');
|
|
298
|
+
}
|
|
299
|
+
return this.#downloadMedia(value);
|
|
300
|
+
}
|
|
301
|
+
async uploadMedia({ type, bytes, filename, contentType, }) {
|
|
302
|
+
const limit = MEDIA_LIMITS[type];
|
|
303
|
+
if (!limit) {
|
|
304
|
+
throw new Error(`Unsupported WeCom media type: ${type}`);
|
|
305
|
+
}
|
|
306
|
+
if (!Buffer.isBuffer(bytes) || bytes.length <= 5 || bytes.length > limit) {
|
|
307
|
+
throw new Error(`WeCom ${type} media must contain 6 to ${limit} bytes`);
|
|
308
|
+
}
|
|
309
|
+
return this.#uploadMedia({ type, bytes, filename, contentType });
|
|
310
|
+
}
|
|
311
|
+
}
|