@nextclaw/channel-extension-weixin 0.1.1
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 +5 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +12 -0
- package/dist/services/weixin-api.service.d.ts +125 -0
- package/dist/services/weixin-api.service.js +182 -0
- package/dist/services/weixin-auth-capability.service.d.ts +20 -0
- package/dist/services/weixin-auth-capability.service.js +33 -0
- package/dist/services/weixin-channel-adapter.service.d.ts +53 -0
- package/dist/services/weixin-channel-adapter.service.js +303 -0
- package/dist/services/weixin-extension-runtime.service.d.ts +19 -0
- package/dist/services/weixin-extension-runtime.service.js +65 -0
- package/dist/services/weixin-login.service.d.ts +55 -0
- package/dist/services/weixin-login.service.js +203 -0
- package/dist/services/weixin-media-part-reader.service.js +192 -0
- package/dist/services/weixin-reply-chat.service.js +141 -0
- package/dist/services/weixin-typing-controller.service.js +109 -0
- package/dist/stores/weixin-account.store.d.ts +19 -0
- package/dist/stores/weixin-account.store.js +56 -0
- package/dist/types/weixin-extension.types.d.ts +43 -0
- package/dist/utils/weixin-config.utils.d.ts +69 -0
- package/dist/utils/weixin-config.utils.js +107 -0
- package/dist/utils/weixin-inbound-media.utils.js +215 -0
- package/dist/utils/weixin-media.utils.js +168 -0
- package/dist/utils/weixin-session-route.utils.js +38 -0
- package/nextclaw.extension.json +58 -0
- package/package.json +36 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//#region src/utils/weixin-config.utils.ts
|
|
2
|
+
const WEIXIN_EXTENSION_ID = "nextclaw-channel-extension-weixin";
|
|
3
|
+
const WEIXIN_CHANNEL_ID = "weixin";
|
|
4
|
+
const DEFAULT_WEIXIN_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
5
|
+
function toRecord(value) {
|
|
6
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function readString(value) {
|
|
10
|
+
if (typeof value !== "string") return;
|
|
11
|
+
return value.trim() || void 0;
|
|
12
|
+
}
|
|
13
|
+
function readStringArray(value) {
|
|
14
|
+
if (!Array.isArray(value)) return;
|
|
15
|
+
const values = value.map((entry) => readString(entry)).filter((entry) => Boolean(entry));
|
|
16
|
+
return values.length > 0 ? values : void 0;
|
|
17
|
+
}
|
|
18
|
+
function normalizeAccountConfig(value) {
|
|
19
|
+
const record = toRecord(value);
|
|
20
|
+
if (!record) return;
|
|
21
|
+
return {
|
|
22
|
+
enabled: typeof record.enabled === "boolean" ? record.enabled : void 0,
|
|
23
|
+
baseUrl: readString(record.baseUrl),
|
|
24
|
+
allowFrom: readStringArray(record.allowFrom)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function normalizeWeixinChannelConfig(value) {
|
|
28
|
+
const record = toRecord(value);
|
|
29
|
+
if (!record) return {};
|
|
30
|
+
const accounts = {};
|
|
31
|
+
for (const [accountId, rawAccountConfig] of Object.entries(toRecord(record.accounts) ?? {})) {
|
|
32
|
+
const normalized = normalizeAccountConfig(rawAccountConfig);
|
|
33
|
+
if (normalized) accounts[accountId] = normalized;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
enabled: typeof record.enabled === "boolean" ? record.enabled : void 0,
|
|
37
|
+
defaultAccountId: readString(record.defaultAccountId),
|
|
38
|
+
baseUrl: readString(record.baseUrl),
|
|
39
|
+
pollTimeoutMs: typeof record.pollTimeoutMs === "number" && Number.isFinite(record.pollTimeoutMs) ? Math.max(1e3, Math.trunc(record.pollTimeoutMs)) : void 0,
|
|
40
|
+
allowFrom: readStringArray(record.allowFrom),
|
|
41
|
+
accounts: Object.keys(accounts).length > 0 ? accounts : void 0
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function buildLoggedInWeixinChannelConfig(params) {
|
|
45
|
+
const { accountId, allowUserId, baseUrl, config, replaceAccountIds } = params;
|
|
46
|
+
const current = normalizeWeixinChannelConfig(config);
|
|
47
|
+
const replacementIds = new Set((replaceAccountIds ?? []).map((accountId) => readString(accountId)).filter((replacementAccountId) => Boolean(replacementAccountId) && replacementAccountId !== accountId));
|
|
48
|
+
const accounts = Object.fromEntries(Object.entries(current.accounts ?? {}).filter(([accountId]) => !replacementIds.has(accountId)));
|
|
49
|
+
const currentAccount = current.accounts?.[accountId] ?? {};
|
|
50
|
+
const allowFrom = new Set([...currentAccount.allowFrom ?? [], ...allowUserId ? [allowUserId] : []]);
|
|
51
|
+
return {
|
|
52
|
+
...current,
|
|
53
|
+
enabled: true,
|
|
54
|
+
defaultAccountId: accountId,
|
|
55
|
+
baseUrl: current.baseUrl ?? baseUrl,
|
|
56
|
+
accounts: {
|
|
57
|
+
...accounts,
|
|
58
|
+
[accountId]: {
|
|
59
|
+
...currentAccount,
|
|
60
|
+
enabled: true,
|
|
61
|
+
baseUrl,
|
|
62
|
+
allowFrom: allowFrom.size > 0 ? [...allowFrom] : void 0
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const WEIXIN_CHANNEL_CONFIG_SCHEMA = {
|
|
68
|
+
type: "object",
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
properties: {
|
|
71
|
+
enabled: { type: "boolean" },
|
|
72
|
+
defaultAccountId: { type: "string" },
|
|
73
|
+
baseUrl: { type: "string" },
|
|
74
|
+
pollTimeoutMs: { type: "number" },
|
|
75
|
+
allowFrom: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string" }
|
|
78
|
+
},
|
|
79
|
+
accounts: {
|
|
80
|
+
type: "object",
|
|
81
|
+
additionalProperties: {
|
|
82
|
+
type: "object",
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
properties: {
|
|
85
|
+
enabled: { type: "boolean" },
|
|
86
|
+
baseUrl: { type: "string" },
|
|
87
|
+
allowFrom: {
|
|
88
|
+
type: "array",
|
|
89
|
+
items: { type: "string" }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const WEIXIN_CHANNEL_CONFIG_UI_HINTS = {
|
|
97
|
+
enabled: { label: "Enabled" },
|
|
98
|
+
defaultAccountId: { label: "Default Account ID" },
|
|
99
|
+
baseUrl: { label: "API Base URL" },
|
|
100
|
+
pollTimeoutMs: {
|
|
101
|
+
label: "Long Poll Timeout (ms)",
|
|
102
|
+
advanced: true
|
|
103
|
+
},
|
|
104
|
+
allowFrom: { label: "Allow From" }
|
|
105
|
+
};
|
|
106
|
+
//#endregion
|
|
107
|
+
export { DEFAULT_WEIXIN_BASE_URL, WEIXIN_CHANNEL_CONFIG_SCHEMA, WEIXIN_CHANNEL_CONFIG_UI_HINTS, WEIXIN_CHANNEL_ID, WEIXIN_EXTENSION_ID, buildLoggedInWeixinChannelConfig, normalizeWeixinChannelConfig };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createDecipheriv, randomUUID } from "node:crypto";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { extname, resolve } from "node:path";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
//#region src/utils/weixin-inbound-media.utils.ts
|
|
6
|
+
const WEIXIN_INBOUND_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
7
|
+
const FILE_EXTENSION_MIME_MAP = {
|
|
8
|
+
".bmp": "image/bmp",
|
|
9
|
+
".cjs": "text/javascript",
|
|
10
|
+
".css": "text/css",
|
|
11
|
+
".csv": "text/csv",
|
|
12
|
+
".gif": "image/gif",
|
|
13
|
+
".go": "text/plain",
|
|
14
|
+
".html": "text/html",
|
|
15
|
+
".java": "text/plain",
|
|
16
|
+
".js": "text/javascript",
|
|
17
|
+
".json": "application/json",
|
|
18
|
+
".jsx": "text/plain",
|
|
19
|
+
".md": "text/markdown",
|
|
20
|
+
".markdown": "text/markdown",
|
|
21
|
+
".mjs": "text/javascript",
|
|
22
|
+
".pdf": "application/pdf",
|
|
23
|
+
".php": "text/plain",
|
|
24
|
+
".png": "image/png",
|
|
25
|
+
".py": "text/plain",
|
|
26
|
+
".rb": "text/plain",
|
|
27
|
+
".rs": "text/plain",
|
|
28
|
+
".scss": "text/x-scss",
|
|
29
|
+
".sh": "text/x-shellscript",
|
|
30
|
+
".sql": "application/sql",
|
|
31
|
+
".svg": "image/svg+xml",
|
|
32
|
+
".swift": "text/plain",
|
|
33
|
+
".ts": "text/plain",
|
|
34
|
+
".tsx": "text/plain",
|
|
35
|
+
".txt": "text/plain",
|
|
36
|
+
".webp": "image/webp",
|
|
37
|
+
".xml": "application/xml",
|
|
38
|
+
".yaml": "application/yaml",
|
|
39
|
+
".yml": "application/yaml"
|
|
40
|
+
};
|
|
41
|
+
function detectMimeFromBuffer(buffer) {
|
|
42
|
+
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([
|
|
43
|
+
137,
|
|
44
|
+
80,
|
|
45
|
+
78,
|
|
46
|
+
71,
|
|
47
|
+
13,
|
|
48
|
+
10,
|
|
49
|
+
26,
|
|
50
|
+
10
|
|
51
|
+
]))) return "image/png";
|
|
52
|
+
if (buffer.length >= 3 && buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) return "image/jpeg";
|
|
53
|
+
if (buffer.length >= 6 && ["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) return "image/gif";
|
|
54
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
|
55
|
+
if (buffer.length >= 4 && buffer.subarray(0, 4).toString("ascii") === "%PDF") return "application/pdf";
|
|
56
|
+
}
|
|
57
|
+
function detectMimeFromFileName(fileName) {
|
|
58
|
+
const extension = extname(fileName ?? "").toLowerCase();
|
|
59
|
+
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
|
60
|
+
return FILE_EXTENSION_MIME_MAP[extension];
|
|
61
|
+
}
|
|
62
|
+
function mimeToExtension(contentType, fileName) {
|
|
63
|
+
const fileExtension = extname(fileName ?? "").trim();
|
|
64
|
+
if (fileExtension) return fileExtension;
|
|
65
|
+
switch (contentType) {
|
|
66
|
+
case "image/png": return ".png";
|
|
67
|
+
case "image/jpeg": return ".jpg";
|
|
68
|
+
case "image/gif": return ".gif";
|
|
69
|
+
case "image/webp": return ".webp";
|
|
70
|
+
case "application/pdf": return ".pdf";
|
|
71
|
+
default: return ".bin";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function preferSpecificContentType(params) {
|
|
75
|
+
const { detectedContentType, fileName, reportedContentType } = params;
|
|
76
|
+
const normalizedReported = reportedContentType?.trim().toLowerCase();
|
|
77
|
+
if (normalizedReported && normalizedReported !== "application/octet-stream") return normalizedReported;
|
|
78
|
+
return detectedContentType ?? detectMimeFromFileName(fileName) ?? reportedContentType;
|
|
79
|
+
}
|
|
80
|
+
async function saveMediaBuffer(buffer, contentType, fileName) {
|
|
81
|
+
if (buffer.length > WEIXIN_INBOUND_MEDIA_MAX_BYTES) throw new Error(`media exceeds maxBytes (${buffer.length} > ${WEIXIN_INBOUND_MEDIA_MAX_BYTES})`);
|
|
82
|
+
const resolvedContentType = preferSpecificContentType({
|
|
83
|
+
reportedContentType: contentType,
|
|
84
|
+
detectedContentType: detectMimeFromBuffer(buffer),
|
|
85
|
+
fileName
|
|
86
|
+
});
|
|
87
|
+
const targetDir = resolve(tmpdir(), "nextclaw-media", "inbound");
|
|
88
|
+
await mkdir(targetDir, { recursive: true });
|
|
89
|
+
const targetPath = resolve(targetDir, `${randomUUID()}${mimeToExtension(resolvedContentType, fileName)}`);
|
|
90
|
+
await writeFile(targetPath, buffer);
|
|
91
|
+
return {
|
|
92
|
+
path: targetPath,
|
|
93
|
+
contentType: resolvedContentType
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function parseEncodedAesKey(aesKeyBase64) {
|
|
97
|
+
const decoded = Buffer.from(aesKeyBase64, "base64");
|
|
98
|
+
if (decoded.length === 16) return decoded;
|
|
99
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) return Buffer.from(decoded.toString("ascii"), "hex");
|
|
100
|
+
throw new Error(`unsupported aes_key payload (${decoded.length} bytes after base64 decode)`);
|
|
101
|
+
}
|
|
102
|
+
function parseAesKey(media, imageItem) {
|
|
103
|
+
const imageHexKey = imageItem?.aeskey?.trim();
|
|
104
|
+
if (imageHexKey) return Buffer.from(imageHexKey, "hex");
|
|
105
|
+
const encoded = media?.aes_key?.trim();
|
|
106
|
+
if (!encoded) return;
|
|
107
|
+
return parseEncodedAesKey(encoded);
|
|
108
|
+
}
|
|
109
|
+
function decryptAesEcb(ciphertext, key) {
|
|
110
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
111
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
112
|
+
}
|
|
113
|
+
function buildFallbackDownloadUrl(baseUrl, encryptedQueryParam) {
|
|
114
|
+
return `${new URL(baseUrl).origin}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
|
|
115
|
+
}
|
|
116
|
+
function resolveMediaUrl(media, baseUrl) {
|
|
117
|
+
const fullUrl = media?.full_url?.trim();
|
|
118
|
+
if (fullUrl) return fullUrl;
|
|
119
|
+
const encryptedQueryParam = media?.encrypt_query_param?.trim();
|
|
120
|
+
if (!encryptedQueryParam) return;
|
|
121
|
+
return buildFallbackDownloadUrl(baseUrl, encryptedQueryParam);
|
|
122
|
+
}
|
|
123
|
+
async function fetchMediaBuffer(url) {
|
|
124
|
+
const response = await fetch(url);
|
|
125
|
+
if (!response.ok) throw new Error(`download failed: ${response.status} ${response.statusText}`);
|
|
126
|
+
return {
|
|
127
|
+
buffer: Buffer.from(await response.arrayBuffer()),
|
|
128
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function resolveImageAttachment(params) {
|
|
132
|
+
const imageItem = params.item.image_item;
|
|
133
|
+
const media = imageItem?.media;
|
|
134
|
+
const url = resolveMediaUrl(media, params.baseUrl);
|
|
135
|
+
if (!url) return null;
|
|
136
|
+
try {
|
|
137
|
+
const { buffer, contentType } = await fetchMediaBuffer(url);
|
|
138
|
+
const key = parseAesKey(media, imageItem);
|
|
139
|
+
const plaintext = key ? decryptAesEcb(buffer, key) : buffer;
|
|
140
|
+
const saved = await saveMediaBuffer(plaintext, contentType);
|
|
141
|
+
return {
|
|
142
|
+
path: saved.path,
|
|
143
|
+
url,
|
|
144
|
+
mimeType: saved.contentType ?? detectMimeFromBuffer(plaintext) ?? "image/*",
|
|
145
|
+
size: plaintext.length,
|
|
146
|
+
source: "weixin",
|
|
147
|
+
status: "ready"
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return {
|
|
151
|
+
url,
|
|
152
|
+
mimeType: "image/*",
|
|
153
|
+
source: "weixin",
|
|
154
|
+
status: "remote-only",
|
|
155
|
+
errorCode: "download_failed"
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function resolveFileAttachment(params) {
|
|
160
|
+
const fileItem = params.item.file_item;
|
|
161
|
+
const media = fileItem?.media;
|
|
162
|
+
const fileName = fileItem?.file_name?.trim();
|
|
163
|
+
const url = resolveMediaUrl(media, params.baseUrl);
|
|
164
|
+
const hintedMimeType = detectMimeFromFileName(fileName);
|
|
165
|
+
if (!url) return fileName ? {
|
|
166
|
+
name: fileName,
|
|
167
|
+
mimeType: hintedMimeType,
|
|
168
|
+
source: "weixin",
|
|
169
|
+
status: "remote-only",
|
|
170
|
+
errorCode: "invalid_payload"
|
|
171
|
+
} : null;
|
|
172
|
+
try {
|
|
173
|
+
const { buffer, contentType } = await fetchMediaBuffer(url);
|
|
174
|
+
const key = parseAesKey(media);
|
|
175
|
+
const plaintext = key ? decryptAesEcb(buffer, key) : buffer;
|
|
176
|
+
const saved = await saveMediaBuffer(plaintext, contentType ?? hintedMimeType, fileName);
|
|
177
|
+
return {
|
|
178
|
+
name: fileName,
|
|
179
|
+
path: saved.path,
|
|
180
|
+
url,
|
|
181
|
+
mimeType: saved.contentType ?? hintedMimeType ?? "application/octet-stream",
|
|
182
|
+
size: plaintext.length,
|
|
183
|
+
source: "weixin",
|
|
184
|
+
status: "ready"
|
|
185
|
+
};
|
|
186
|
+
} catch {
|
|
187
|
+
return {
|
|
188
|
+
name: fileName,
|
|
189
|
+
url,
|
|
190
|
+
mimeType: hintedMimeType,
|
|
191
|
+
source: "weixin",
|
|
192
|
+
status: "remote-only",
|
|
193
|
+
errorCode: "download_failed"
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function resolveAttachmentFromItem(params) {
|
|
198
|
+
if (params.item.type === 2) return resolveImageAttachment(params);
|
|
199
|
+
if (params.item.type === 4) return resolveFileAttachment(params);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
async function resolveWeixinInboundAttachments(params) {
|
|
203
|
+
const items = Array.isArray(params.message.item_list) ? params.message.item_list : [];
|
|
204
|
+
const attachments = [];
|
|
205
|
+
for (const item of items) {
|
|
206
|
+
const attachment = await resolveAttachmentFromItem({
|
|
207
|
+
item,
|
|
208
|
+
baseUrl: params.baseUrl
|
|
209
|
+
});
|
|
210
|
+
if (attachment) attachments.push(attachment);
|
|
211
|
+
}
|
|
212
|
+
return attachments;
|
|
213
|
+
}
|
|
214
|
+
//#endregion
|
|
215
|
+
export { resolveWeixinInboundAttachments };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { WEIXIN_API_TIMEOUT_MS, buildWeixinBaseInfo, fetchWeixinJson, normalizeWeixinBaseUrl, sendWeixinMessageItem } from "../services/weixin-api.service.js";
|
|
2
|
+
import { createCipheriv, createHash, randomBytes } from "node:crypto";
|
|
3
|
+
//#region src/utils/weixin-media.utils.ts
|
|
4
|
+
const WEIXIN_UPLOAD_TIMEOUT_MS = 6e4;
|
|
5
|
+
function buildWeixinUploadUrl(params) {
|
|
6
|
+
return `${new URL(params.baseUrl).origin}/upload?${new URLSearchParams({
|
|
7
|
+
encrypted_query_param: params.uploadParam,
|
|
8
|
+
filekey: params.fileKey
|
|
9
|
+
}).toString()}`;
|
|
10
|
+
}
|
|
11
|
+
function computeEncryptedSize(size) {
|
|
12
|
+
const blockSize = 16;
|
|
13
|
+
const remainder = size % blockSize;
|
|
14
|
+
return size + (remainder === 0 ? blockSize : blockSize - remainder);
|
|
15
|
+
}
|
|
16
|
+
function encryptWeixinBytes(bytes, aesKey) {
|
|
17
|
+
const cipher = createCipheriv("aes-128-ecb", aesKey, null);
|
|
18
|
+
return Buffer.concat([cipher.update(Buffer.from(bytes)), cipher.final()]);
|
|
19
|
+
}
|
|
20
|
+
function encodeWeixinMediaAesKey(aesKeyHex) {
|
|
21
|
+
return Buffer.from(aesKeyHex, "utf8").toString("base64");
|
|
22
|
+
}
|
|
23
|
+
async function withUploadTimeout(params) {
|
|
24
|
+
const { handler, signal: parentSignal, timeoutMs } = params;
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timeout = setTimeout(() => controller.abort(), Math.max(1e3, timeoutMs));
|
|
27
|
+
const abort = () => controller.abort();
|
|
28
|
+
parentSignal?.addEventListener("abort", abort, { once: true });
|
|
29
|
+
try {
|
|
30
|
+
return await handler(controller.signal);
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(timeout);
|
|
33
|
+
parentSignal?.removeEventListener("abort", abort);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function fetchWeixinUploadUrl(params) {
|
|
37
|
+
const { aesKeyHex, baseUrl, bytes, fileKey, mediaType, signal, toUserId, token } = params;
|
|
38
|
+
const rawFileMd5 = createHash("md5").update(Buffer.from(bytes)).digest("hex");
|
|
39
|
+
return await fetchWeixinJson({
|
|
40
|
+
url: new URL("ilink/bot/getuploadurl", normalizeWeixinBaseUrl(baseUrl)).toString(),
|
|
41
|
+
token,
|
|
42
|
+
timeoutMs: WEIXIN_API_TIMEOUT_MS,
|
|
43
|
+
signal,
|
|
44
|
+
body: {
|
|
45
|
+
filekey: fileKey,
|
|
46
|
+
media_type: mediaType,
|
|
47
|
+
to_user_id: toUserId,
|
|
48
|
+
rawsize: bytes.byteLength,
|
|
49
|
+
rawfilemd5: rawFileMd5,
|
|
50
|
+
filesize: computeEncryptedSize(bytes.byteLength),
|
|
51
|
+
no_need_thumb: true,
|
|
52
|
+
aeskey: aesKeyHex,
|
|
53
|
+
base_info: buildWeixinBaseInfo()
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function uploadWeixinMedia(params) {
|
|
58
|
+
const { aesKey, baseUrl, bytes, fileKey, signal, uploadFullUrl, uploadParam } = params;
|
|
59
|
+
const uploadUrl = uploadFullUrl?.trim() ? uploadFullUrl.trim() : uploadParam ? buildWeixinUploadUrl({
|
|
60
|
+
baseUrl,
|
|
61
|
+
uploadParam,
|
|
62
|
+
fileKey
|
|
63
|
+
}) : null;
|
|
64
|
+
if (!uploadUrl) throw new Error("weixin upload failed: upload url is missing");
|
|
65
|
+
const ciphertext = encryptWeixinBytes(bytes, aesKey);
|
|
66
|
+
const response = await withUploadTimeout({
|
|
67
|
+
timeoutMs: WEIXIN_UPLOAD_TIMEOUT_MS,
|
|
68
|
+
signal,
|
|
69
|
+
handler: async (signal) => fetch(uploadUrl, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
72
|
+
body: new Uint8Array(ciphertext),
|
|
73
|
+
signal
|
|
74
|
+
})
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const message = await response.text();
|
|
78
|
+
throw new Error(`weixin upload failed: ${response.status} ${message || response.statusText}`);
|
|
79
|
+
}
|
|
80
|
+
const downloadEncryptedQueryParam = response.headers.get("x-encrypted-param")?.trim();
|
|
81
|
+
if (!downloadEncryptedQueryParam) throw new Error("weixin upload failed: x-encrypted-param is missing");
|
|
82
|
+
return {
|
|
83
|
+
downloadEncryptedQueryParam,
|
|
84
|
+
aesKeyHex: aesKey.toString("hex"),
|
|
85
|
+
fileSize: bytes.byteLength,
|
|
86
|
+
fileSizeCiphertext: ciphertext.byteLength
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function sendUploadedWeixinMediaItem(params) {
|
|
90
|
+
const { baseUrl, bytes, contextToken, item, mediaType, signal, toUserId, token } = params;
|
|
91
|
+
const fileKey = randomBytes(16).toString("hex");
|
|
92
|
+
const aesKey = randomBytes(16);
|
|
93
|
+
const uploadUrl = await fetchWeixinUploadUrl({
|
|
94
|
+
baseUrl,
|
|
95
|
+
token,
|
|
96
|
+
toUserId,
|
|
97
|
+
fileKey,
|
|
98
|
+
mediaType,
|
|
99
|
+
bytes,
|
|
100
|
+
aesKeyHex: aesKey.toString("hex"),
|
|
101
|
+
signal
|
|
102
|
+
});
|
|
103
|
+
return await sendWeixinMessageItem({
|
|
104
|
+
baseUrl,
|
|
105
|
+
token,
|
|
106
|
+
toUserId,
|
|
107
|
+
contextToken,
|
|
108
|
+
signal,
|
|
109
|
+
item: item(await uploadWeixinMedia({
|
|
110
|
+
baseUrl,
|
|
111
|
+
uploadParam: uploadUrl.upload_param,
|
|
112
|
+
uploadFullUrl: uploadUrl.upload_full_url,
|
|
113
|
+
fileKey,
|
|
114
|
+
bytes,
|
|
115
|
+
aesKey,
|
|
116
|
+
signal
|
|
117
|
+
}))
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function sendWeixinFileMessage(params) {
|
|
121
|
+
const { baseUrl, bytes, contextToken, fileName, signal, toUserId, token } = params;
|
|
122
|
+
return await sendUploadedWeixinMediaItem({
|
|
123
|
+
baseUrl,
|
|
124
|
+
token,
|
|
125
|
+
toUserId,
|
|
126
|
+
bytes,
|
|
127
|
+
contextToken,
|
|
128
|
+
mediaType: 3,
|
|
129
|
+
signal,
|
|
130
|
+
item: (uploaded) => ({
|
|
131
|
+
type: 4,
|
|
132
|
+
file_item: {
|
|
133
|
+
file_name: fileName,
|
|
134
|
+
len: String(uploaded.fileSize),
|
|
135
|
+
media: {
|
|
136
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
137
|
+
aes_key: encodeWeixinMediaAesKey(uploaded.aesKeyHex),
|
|
138
|
+
encrypt_type: 1
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async function sendWeixinImageMessage(params) {
|
|
145
|
+
const { baseUrl, bytes, contextToken, signal, toUserId, token } = params;
|
|
146
|
+
return await sendUploadedWeixinMediaItem({
|
|
147
|
+
baseUrl,
|
|
148
|
+
token,
|
|
149
|
+
toUserId,
|
|
150
|
+
bytes,
|
|
151
|
+
contextToken,
|
|
152
|
+
mediaType: 1,
|
|
153
|
+
signal,
|
|
154
|
+
item: (uploaded) => ({
|
|
155
|
+
type: 2,
|
|
156
|
+
image_item: {
|
|
157
|
+
media: {
|
|
158
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
159
|
+
aes_key: encodeWeixinMediaAesKey(uploaded.aesKeyHex),
|
|
160
|
+
encrypt_type: 1
|
|
161
|
+
},
|
|
162
|
+
mid_size: uploaded.fileSizeCiphertext
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
export { sendWeixinFileMessage, sendWeixinImageMessage };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/utils/weixin-session-route.utils.ts
|
|
2
|
+
const PEER_KINDS = new Set([
|
|
3
|
+
"direct",
|
|
4
|
+
"group",
|
|
5
|
+
"channel"
|
|
6
|
+
]);
|
|
7
|
+
function readPayload(value) {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
9
|
+
return value.payload;
|
|
10
|
+
}
|
|
11
|
+
function readNestedMessageSessionId(payload) {
|
|
12
|
+
const message = payload.message;
|
|
13
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return;
|
|
14
|
+
const sessionId = message.sessionId;
|
|
15
|
+
return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : void 0;
|
|
16
|
+
}
|
|
17
|
+
function readWeixinEventSessionId(event) {
|
|
18
|
+
const payload = readPayload(event);
|
|
19
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
|
|
20
|
+
const sessionId = payload.sessionId;
|
|
21
|
+
if (typeof sessionId === "string" && sessionId.trim()) return sessionId.trim();
|
|
22
|
+
return readNestedMessageSessionId(payload);
|
|
23
|
+
}
|
|
24
|
+
function resolveWeixinSessionRoute(event) {
|
|
25
|
+
const sessionId = readWeixinEventSessionId(event);
|
|
26
|
+
if (!sessionId) return null;
|
|
27
|
+
const parts = sessionId.split(":");
|
|
28
|
+
const normalizedParts = parts.map((part) => part.toLowerCase());
|
|
29
|
+
if (normalizedParts[0] !== "agent" || parts.length < 5) return null;
|
|
30
|
+
if (normalizedParts[2] === "weixin" && PEER_KINDS.has(normalizedParts[3] ?? "") && parts.length >= 5) return { conversationId: parts.slice(4).join(":") };
|
|
31
|
+
if (normalizedParts[2] === "weixin" && PEER_KINDS.has(normalizedParts[4] ?? "") && parts.length >= 6) return {
|
|
32
|
+
accountId: parts[3],
|
|
33
|
+
conversationId: parts.slice(5).join(":")
|
|
34
|
+
};
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
export { readWeixinEventSessionId, resolveWeixinSessionRoute };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nextclaw-channel-extension-weixin",
|
|
3
|
+
"name": "NextClaw Weixin Channel Extension",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"server": {
|
|
6
|
+
"type": "stdio",
|
|
7
|
+
"command": "node",
|
|
8
|
+
"args": ["dist/main.js"]
|
|
9
|
+
},
|
|
10
|
+
"contributes": {
|
|
11
|
+
"channels": [
|
|
12
|
+
{
|
|
13
|
+
"id": "weixin",
|
|
14
|
+
"name": "Weixin",
|
|
15
|
+
"description": "Weixin QR login + getupdates long-poll channel",
|
|
16
|
+
"auth": {
|
|
17
|
+
"type": "request-response"
|
|
18
|
+
},
|
|
19
|
+
"configUiHints": {
|
|
20
|
+
"enabled": { "label": "Enabled" },
|
|
21
|
+
"defaultAccountId": { "label": "Default Account ID" },
|
|
22
|
+
"baseUrl": { "label": "API Base URL" },
|
|
23
|
+
"pollTimeoutMs": { "label": "Long Poll Timeout (ms)", "advanced": true },
|
|
24
|
+
"allowFrom": { "label": "Allow From" }
|
|
25
|
+
},
|
|
26
|
+
"configSchema": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"properties": {
|
|
30
|
+
"enabled": { "type": "boolean" },
|
|
31
|
+
"defaultAccountId": { "type": "string" },
|
|
32
|
+
"baseUrl": { "type": "string" },
|
|
33
|
+
"pollTimeoutMs": { "type": "number" },
|
|
34
|
+
"allowFrom": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": { "type": "string" }
|
|
37
|
+
},
|
|
38
|
+
"accounts": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"properties": {
|
|
44
|
+
"enabled": { "type": "boolean" },
|
|
45
|
+
"baseUrl": { "type": "string" },
|
|
46
|
+
"allowFrom": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"items": { "type": "string" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nextclaw/channel-extension-weixin",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "NextClaw Weixin channel extension process.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"development": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"nextclaw.extension.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@nextclaw/extension-sdk": "0.1.1",
|
|
22
|
+
"@nextclaw/ncp-toolkit": "0.5.12",
|
|
23
|
+
"@nextclaw/ncp": "0.5.7"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20.17.6",
|
|
27
|
+
"typescript": "^5.6.3",
|
|
28
|
+
"vitest": "^4.1.2"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsdown src/index.ts src/main.ts --dts --clean --target es2022 --no-fixedExtension --unbundle",
|
|
32
|
+
"lint": "eslint src --max-warnings=0",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"tsc": "tsc -p tsconfig.json"
|
|
35
|
+
}
|
|
36
|
+
}
|