mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-08-07 17:33:03 +08:00
fix(baoyu-post-to-wechat): make remote-api work under Bun & validate config strictly
The initial remote-api implementation (3b29f3c) relied on
`https.request({ agent: SocksProxyAgent })` to route token/upload/draft
calls through the SSH tunnel. Bun's `https.request` does not honor
Node's `http.Agent` contract, so the agent was silently bypassed and
requests still originated from the local IP — defeating the entire
IP-allowlist purpose. Two follow-on issues compounded it: tests read
the real `~/.baoyu-skills/.env` because Bun's `os.homedir()` ignores
test-time `process.env.HOME` mutations, and invalid config values were
silently coerced to defaults.
P1 — Bun-portable SOCKS routing:
- Drop `socks-proxy-agent` dependency. Add `socks` direct dep.
- New `wechat-socks-http.ts`: raw TCP via `SocksClient.createConnection`
+ `tls.connect({ socket, servername })` + hand-built HTTP/1.1 (status
line parser, case-insensitive headers, chunked & content-length body
framing). Works identically under Node and Bun because it avoids
`http.Agent` entirely.
- Rewrite `wechat-http.ts` as a fetch-based local client and expose
a `WechatClient = (url, init?) => Promise<WechatHttpResponse>`
functional abstraction.
- `wechat-api.ts`: replace `agent?: http.Agent` with
`client: WechatClient = wechatHttp` on the five HTTP-touching
functions; `withSshTunnel` now yields a `WechatClient`.
- New `wechat-socks-http.test.ts` stands up a real SOCKS5 server
stub + HTTP echo server and asserts `connectionCount === 1`,
proving bytes actually traverse the proxy under both runtimes.
P2 — `HOME` honored under Bun:
- `homeDir()` reads `process.env.HOME` / `USERPROFILE` first, falling
back to `os.homedir()`. `loadWechatExtendConfig` and `loadCredentials`
use it, restoring test isolation.
P3 — Strict config validation:
- Replace lenient `toOptional*` helpers with `parsePort` /
`parsePositiveInt` / `parseStrictHostKeyChecking` that throw with
the key name. `loadWechatExtendConfig` only catches file-read
errors so parse errors surface to the caller. Flip the corresponding
test cases.
Verification:
- `npm test`: 261/261 pass.
- `bun test` in `scripts/`: 39/39 pass.
Co-authored-by: Dame5211 <1079825614@qq.com>
This commit is contained in:
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type http from "node:http";
|
||||
import {
|
||||
loadWechatExtendConfig,
|
||||
resolveAccount,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
needsWechatBodyImageProcessing,
|
||||
} from "./wechat-image-processor.ts";
|
||||
import { loadUploadAsset } from "./wechat-image-loader.ts";
|
||||
import { wechatHttp, buildMultipart } from "./wechat-http.ts";
|
||||
import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts";
|
||||
import {
|
||||
type RemotePublishConfig,
|
||||
normalizeRemoteConfig,
|
||||
@@ -75,9 +74,13 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
||||
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||
|
||||
async function fetchAccessToken(appId: string, appSecret: string, agent?: http.Agent): Promise<string> {
|
||||
async function fetchAccessToken(
|
||||
appId: string,
|
||||
appSecret: string,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<string> {
|
||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||
const res = await wechatHttp(url, { agent });
|
||||
const res = await client(url);
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Failed to fetch access token: ${res.status}`);
|
||||
}
|
||||
@@ -101,7 +104,7 @@ async function uploadImage(
|
||||
accessToken: string,
|
||||
baseDir?: string,
|
||||
uploadType: "body" | "material" = "body",
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||
let uploadAsset = asset;
|
||||
@@ -126,7 +129,7 @@ async function uploadImage(
|
||||
uploadAsset.contentType,
|
||||
accessToken,
|
||||
uploadType,
|
||||
agent,
|
||||
client,
|
||||
);
|
||||
|
||||
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||
@@ -147,7 +150,7 @@ async function uploadToWechat(
|
||||
contentType: string,
|
||||
accessToken: string,
|
||||
uploadType: "body" | "material",
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename, contentType, data: fileBuffer },
|
||||
@@ -155,11 +158,10 @@ async function uploadToWechat(
|
||||
|
||||
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||
const res = await wechatHttp(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
agent,
|
||||
});
|
||||
|
||||
const data = await res.json<UploadResponse>();
|
||||
@@ -177,7 +179,7 @@ async function uploadImagesInHtml(
|
||||
contentImages: ImageInfo[] = [],
|
||||
articleType: ArticleType = "news",
|
||||
collectNewsCoverFallback: boolean = false,
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
@@ -198,7 +200,7 @@ async function uploadImagesInHtml(
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||
try {
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material", agent);
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material", client);
|
||||
firstCoverMediaId = coverResp.media_id;
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||
@@ -214,7 +216,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent);
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
const newTag = fullTag
|
||||
@@ -225,7 +227,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent);
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -249,7 +251,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent);
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
|
||||
@@ -259,7 +261,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent);
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -280,7 +282,7 @@ async function uploadImagesInHtml(
|
||||
async function publishToDraft(
|
||||
options: ArticleOptions,
|
||||
accessToken: string,
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<PublishResponse> {
|
||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||
|
||||
@@ -317,11 +319,10 @@ async function publishToDraft(
|
||||
if (options.digest) article.digest = options.digest;
|
||||
}
|
||||
|
||||
const res = await wechatHttp(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ articles: [article] }),
|
||||
agent,
|
||||
});
|
||||
|
||||
const data = await res.json<PublishResponse>();
|
||||
@@ -732,9 +733,9 @@ async function main(): Promise<void> {
|
||||
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
|
||||
const method = useRemote ? "remote-api" : "api";
|
||||
|
||||
const publishWithAgent = async (agent?: http.Agent): Promise<void> => {
|
||||
const publishWith = async (client: WechatClient): Promise<void> => {
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, agent);
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);
|
||||
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
@@ -744,7 +745,7 @@ async function main(): Promise<void> {
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
agent,
|
||||
client,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
|
||||
@@ -752,7 +753,7 @@ async function main(): Promise<void> {
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", agent);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client);
|
||||
thumbMediaId = coverResp.media_id;
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
@@ -779,7 +780,7 @@ async function main(): Promise<void> {
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken, agent);
|
||||
}, accessToken, client);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
@@ -797,11 +798,11 @@ async function main(): Promise<void> {
|
||||
console.error(
|
||||
`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,
|
||||
);
|
||||
await withSshTunnel(remoteConfig, async (agent) => {
|
||||
await publishWithAgent(agent);
|
||||
await withSshTunnel(remoteConfig, async (client) => {
|
||||
await publishWith(client);
|
||||
});
|
||||
} else {
|
||||
await publishWithAgent();
|
||||
await publishWith(wechatHttp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user