添加完整的微信插件测试框架,包含: - 微信插件源码(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>
86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
import { decryptAesEcb } from "./aes-ecb.js";
|
|
import { buildCdnDownloadUrl } from "./cdn-url.js";
|
|
import { logger } from "../util/logger.js";
|
|
|
|
/**
|
|
* Download raw bytes from the CDN (no decryption).
|
|
*/
|
|
async function fetchCdnBytes(url: string, label: string): Promise<Buffer> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url);
|
|
} catch (err) {
|
|
const cause =
|
|
(err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "(no cause)";
|
|
logger.error(
|
|
`${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`,
|
|
);
|
|
throw err;
|
|
}
|
|
logger.debug(`${label}: response status=${res.status} ok=${res.ok}`);
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "(unreadable)");
|
|
const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`;
|
|
logger.error(msg);
|
|
throw new Error(msg);
|
|
}
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|
|
|
|
/**
|
|
* Parse CDNMedia.aes_key into a raw 16-byte AES key.
|
|
*
|
|
* Two encodings are seen in the wild:
|
|
* - base64(raw 16 bytes) → images (aes_key from media field)
|
|
* - base64(hex string of 16 bytes) → file / voice / video
|
|
*
|
|
* In the second case, base64-decoding yields 32 ASCII hex chars which must
|
|
* then be parsed as hex to recover the actual 16-byte key.
|
|
*/
|
|
function parseAesKey(aesKeyBase64: string, label: string): Buffer {
|
|
const decoded = Buffer.from(aesKeyBase64, "base64");
|
|
if (decoded.length === 16) {
|
|
return decoded;
|
|
}
|
|
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
|
|
// hex-encoded key: base64 → hex string → raw bytes
|
|
return Buffer.from(decoded.toString("ascii"), "hex");
|
|
}
|
|
const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`;
|
|
logger.error(msg);
|
|
throw new Error(msg);
|
|
}
|
|
|
|
/**
|
|
* Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
|
|
* aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats).
|
|
*/
|
|
export async function downloadAndDecryptBuffer(
|
|
encryptedQueryParam: string,
|
|
aesKeyBase64: string,
|
|
cdnBaseUrl: string,
|
|
label: string,
|
|
): Promise<Buffer> {
|
|
const key = parseAesKey(aesKeyBase64, label);
|
|
const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
|
logger.debug(`${label}: fetching url=${url}`);
|
|
const encrypted = await fetchCdnBytes(url, label);
|
|
logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
|
|
const decrypted = decryptAesEcb(encrypted, key);
|
|
logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
|
|
return decrypted;
|
|
}
|
|
|
|
/**
|
|
* Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.
|
|
*/
|
|
export async function downloadPlainCdnBuffer(
|
|
encryptedQueryParam: string,
|
|
cdnBaseUrl: string,
|
|
label: string,
|
|
): Promise<Buffer> {
|
|
const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
|
logger.debug(`${label}: fetching url=${url}`);
|
|
return fetchCdnBytes(url, label);
|
|
}
|