添加完整的微信插件测试框架,包含: - 微信插件源码(weixin-plugin/) - TypeScript 类型定义(types.ts) - 插件编译脚本(scripts/build-weixin-plugin.sh) - 简化测试脚本(scripts/test-weixin-simple.js) - 完整测试脚本(scripts/test-weixin-plugin.js) - 项目配置和依赖管理(package.json) - 使用说明文档(README.md) 主要功能: - 支持二维码登录认证 - HTTP 长轮询消息接收 - 文本、图片、视频、文件消息收发 - 自动回复测试 - 运行状态统计 Generated with [Claude Code](https://claude.com/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { getUploadUrl } from "../api/api.js";
|
|
import type { WeixinApiOptions } from "../api/api.js";
|
|
import { aesEcbPaddedSize } from "./aes-ecb.js";
|
|
import { uploadBufferToCdn } from "./cdn-upload.js";
|
|
import { logger } from "../util/logger.js";
|
|
import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
|
|
import { tempFileName } from "../util/random.js";
|
|
import { UploadMediaType } from "../api/types.js";
|
|
|
|
export type UploadedFileInfo = {
|
|
filekey: string;
|
|
/** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
|
|
downloadEncryptedQueryParam: string;
|
|
/** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */
|
|
aeskey: string;
|
|
/** Plaintext file size in bytes */
|
|
fileSize: number;
|
|
/** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */
|
|
fileSizeCiphertext: number;
|
|
};
|
|
|
|
/**
|
|
* Download a remote media URL (image, video, file) to a local temp file in destDir.
|
|
* Returns the local file path; extension is inferred from Content-Type / URL.
|
|
*/
|
|
export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
|
|
logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
|
|
logger.error(`downloadRemoteImageToTemp: ${msg}`);
|
|
throw new Error(msg);
|
|
}
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
|
|
await fs.mkdir(destDir, { recursive: true });
|
|
const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
|
|
const name = tempFileName("weixin-remote", ext);
|
|
const filePath = path.join(destDir, name);
|
|
await fs.writeFile(filePath, buf);
|
|
logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
|
|
return filePath;
|
|
}
|
|
|
|
/**
|
|
* Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
|
|
*/
|
|
async function uploadMediaToCdn(params: {
|
|
filePath: string;
|
|
toUserId: string;
|
|
opts: WeixinApiOptions;
|
|
cdnBaseUrl: string;
|
|
mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
|
|
label: string;
|
|
}): Promise<UploadedFileInfo> {
|
|
const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
|
|
|
|
const plaintext = await fs.readFile(filePath);
|
|
const rawsize = plaintext.length;
|
|
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
|
const filesize = aesEcbPaddedSize(rawsize);
|
|
const filekey = crypto.randomBytes(16).toString("hex");
|
|
const aeskey = crypto.randomBytes(16);
|
|
|
|
logger.debug(
|
|
`${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
|
|
);
|
|
|
|
const uploadUrlResp = await getUploadUrl({
|
|
...opts,
|
|
filekey,
|
|
media_type: mediaType,
|
|
to_user_id: toUserId,
|
|
rawsize,
|
|
rawfilemd5,
|
|
filesize,
|
|
no_need_thumb: true,
|
|
aeskey: aeskey.toString("hex"),
|
|
});
|
|
|
|
const uploadParam = uploadUrlResp.upload_param;
|
|
if (!uploadParam) {
|
|
logger.error(
|
|
`${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`,
|
|
);
|
|
throw new Error(`${label}: getUploadUrl returned no upload_param`);
|
|
}
|
|
|
|
const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
|
|
buf: plaintext,
|
|
uploadParam,
|
|
filekey,
|
|
cdnBaseUrl,
|
|
aeskey,
|
|
label: `${label}[orig filekey=${filekey}]`,
|
|
});
|
|
|
|
return {
|
|
filekey,
|
|
downloadEncryptedQueryParam,
|
|
aeskey: aeskey.toString("hex"),
|
|
fileSize: rawsize,
|
|
fileSizeCiphertext: filesize,
|
|
};
|
|
}
|
|
|
|
/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
|
|
export async function uploadFileToWeixin(params: {
|
|
filePath: string;
|
|
toUserId: string;
|
|
opts: WeixinApiOptions;
|
|
cdnBaseUrl: string;
|
|
}): Promise<UploadedFileInfo> {
|
|
return uploadMediaToCdn({
|
|
...params,
|
|
mediaType: UploadMediaType.IMAGE,
|
|
label: "uploadFileToWeixin",
|
|
});
|
|
}
|
|
|
|
/** Upload a local video file to the Weixin CDN. */
|
|
export async function uploadVideoToWeixin(params: {
|
|
filePath: string;
|
|
toUserId: string;
|
|
opts: WeixinApiOptions;
|
|
cdnBaseUrl: string;
|
|
}): Promise<UploadedFileInfo> {
|
|
return uploadMediaToCdn({
|
|
...params,
|
|
mediaType: UploadMediaType.VIDEO,
|
|
label: "uploadVideoToWeixin",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upload a local file attachment (non-image, non-video) to the Weixin CDN.
|
|
* Uses media_type=FILE; no thumbnail required.
|
|
*/
|
|
export async function uploadFileAttachmentToWeixin(params: {
|
|
filePath: string;
|
|
fileName: string;
|
|
toUserId: string;
|
|
opts: WeixinApiOptions;
|
|
cdnBaseUrl: string;
|
|
}): Promise<UploadedFileInfo> {
|
|
return uploadMediaToCdn({
|
|
...params,
|
|
mediaType: UploadMediaType.FILE,
|
|
label: "uploadFileAttachmentToWeixin",
|
|
});
|
|
}
|