Compare commits

..

13 Commits

Author SHA1 Message Date
Jim Liu 宝玉 1406a85331 chore: release v1.117.5 2026-05-21 08:55:07 -05:00
Jim Liu 宝玉 dac5867f8a chore: release v1.117.4 2026-05-21 08:52:09 -05:00
Jim Liu 宝玉 49829e7f98 Merge pull request #159 from JimLiu/feat/wechat-remote-api-bun-fix
feat(baoyu-post-to-wechat): remote-api publishing via SSH SOCKS5 tunnel
2026-05-21 08:46:11 -05:00
Jim Liu 宝玉 156f8627c2 ci: install baoyu-post-to-wechat script deps before running tests
`wechat-socks-http.test.ts` (and `wechat-remote-publish.test.ts`
transitively) import `socks` from `wechat-socks-http.ts`. The CI
`npm ci` only installs root deps, so module resolution failed with
`ERR_MODULE_NOT_FOUND: Cannot find package 'socks'`.

Add a scoped `npm install` step for `skills/baoyu-post-to-wechat/scripts/`
with `--ignore-scripts` to skip postinstalls. Other skills with
private script package.json files don't yet have tests that import
their deps, so leaving them out keeps the install fast.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 02:16:31 -05:00
Jim Liu 宝玉 e0b861c148 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>
2026-05-21 02:10:23 -05:00
Jim Liu 宝玉 3b29f3c57c feat(baoyu-post-to-wechat): add remote-api publishing via SSH SOCKS5 tunnel
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist
constraints) with a fully local code path: spawn a background `ssh -N -D`
SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through
the existing token/upload/draft flow. No Python helper, no SCP, no
remote files, no `AppSecret` ever leaving the local process.

Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag
rather than substring-replacing `src`) naturally avoids the original
PR's `html.replace(src, url)` HTML-replacement bug.

Changes:
- New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port
  allocation, SOCKS readiness polling, signal-handler-backed cleanup.
- New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a
  `SocksProxyAgent` can be passed through (undici `fetch` ignores agent).
- New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse.
- `wechat-api.ts`: thread optional `agent?` through token/upload/draft;
  add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote
  mode is selected by flag or `default_publish_method: remote-api`.
- `wechat-extend-config.ts`: add typed `remote_publish_*` keys with
  account-over-global fallback and value validation.
- Docs: SKILL.md, references/multi-account.md, references/config/
  first-time-setup.md, README.md, README.zh.md.
- Tests: 17 new node:test cases covering config parsing, SSH arg
  whitelisting, free-port allocation, multipart assembly, and HTTP
  agent threading.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 00:59:36 -05:00
Jim Liu 宝玉 f8fb457f36 chore: release v1.117.3 2026-05-20 07:58:44 -05:00
Jim Liu 宝玉 174e472a39 docs(baoyu-wechat-summary): restructure profile fields
Split aliases into group_nicknames (user's own prior names) and
aliases (nicknames from other members). Add tags field for
cross-cutting attributes. Sync SKILL.md version.
2026-05-20 07:58:20 -05:00
Jim Liu 宝玉 8b99fa7af0 fix(baoyu-post-to-wechat): sync SKILL.md version to 1.117.3 2026-05-20 07:58:18 -05:00
Jim Liu 宝玉 6699b802c0 fix(baoyu-diagram): add version field to SKILL.md 2026-05-20 07:58:17 -05:00
Jim Liu 宝玉 edcdc19ac5 feat(ci): add skill release commit validation
Add CI check to ensure commits touching skills/<name>/** use
Conventional Commit subjects. Also validates SKILL.md version
alignment during publish/sync.
2026-05-20 07:58:14 -05:00
Jim Liu 宝玉 5d2a39c636 chore: release v1.117.2 2026-05-17 21:10:55 -05:00
Jim Liu 宝玉 db58bdee8c docs(skills): ban programmatic text repair on generated bitmaps
Add a text-correction policy to all raster-image skills: title/subtitle/labels/dialogue/etc. inside generated bitmaps must NOT be patched with ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR overlays, or any other programmatic painter. On error, regenerate from a corrected prompt or switch to a lower-text variant.

Synced to: baoyu-cover-image, baoyu-article-illustrator, baoyu-comic, baoyu-image-cards, baoyu-xhs-images, baoyu-infographic, baoyu-slide-deck.
2026-05-17 21:10:50 -05:00
40 changed files with 2396 additions and 226 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.117.1"
"version": "1.117.5"
},
"plugins": [
{
+10
View File
@@ -11,6 +11,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -18,8 +20,16 @@ jobs:
node-version: 22
cache: npm
- name: Verify skill release commits
run: npm run verify:skill-release-commits
- name: Install dependencies
run: npm ci
- name: Install baoyu-post-to-wechat script dependencies
run: |
cd skills/baoyu-post-to-wechat/scripts
npm install --no-audit --no-fund --ignore-scripts
- name: Run tests
run: npm test
+34
View File
@@ -2,6 +2,40 @@
English | [中文](./CHANGELOG.zh.md)
## 1.117.5 - 2026-05-21
### Credits
- `baoyu-post-to-wechat`: remote API publishing update credited to Dame5211 <1079825614@qq.com>
## 1.117.4 - 2026-05-21
### Features
- `baoyu-post-to-wechat`: add remote API publishing via an SSH SOCKS5 tunnel
### Fixes
- `baoyu-post-to-wechat`: make remote API publishing work under Bun and strictly validate remote publish config
### CI
- Install `baoyu-post-to-wechat` script dependencies before running tests
## 1.117.3 - 2026-05-20
### Features
- CI: add skill release commit validation — commits touching `skills/<name>/**` must use Conventional Commit subjects; SKILL.md version validated during publish/sync
### Fixes
- `baoyu-diagram`: add version field to SKILL.md
- `baoyu-post-to-wechat`: sync SKILL.md version
### Documentation
- `baoyu-wechat-summary`: restructure profile fields — split `aliases` into `group_nicknames` (user's own prior names) and `aliases` (nicknames from other members), add `tags` for cross-cutting attributes
## 1.117.2 - 2026-05-17
### Documentation
- `baoyu-cover-image`: ban programmatic text repair on generated bitmaps — disallow ImageMagick / Pillow / Canvas / SVG / HTML overlays to cover, rewrite, or replace title/subtitle text; regenerate from a corrected prompt or switch to a lower-text or no-title variant instead
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-image-cards`, `baoyu-xhs-images`, `baoyu-infographic`, `baoyu-slide-deck`: sync the same text-repair ban with skill-specific text categories (labels/captions, dialogue/sound effects, titles/body/tags, headings/data values, slide titles/bullets)
## 1.117.1 - 2026-05-16
### Fixes
+34
View File
@@ -2,6 +2,40 @@
[English](./CHANGELOG.md) | 中文
## 1.117.5 - 2026-05-21
### 致谢
- `baoyu-post-to-wechat`:远程 API 发布更新感谢 Dame5211 <1079825614@qq.com>
## 1.117.4 - 2026-05-21
### 新功能
- `baoyu-post-to-wechat`:新增通过 SSH SOCKS5 隧道进行远程 API 发布
### 修复
- `baoyu-post-to-wechat`:修复远程 API 发布在 Bun 下的运行问题,并严格校验远程发布配置
### CI
- 测试前安装 `baoyu-post-to-wechat` 脚本依赖
## 1.117.3 - 2026-05-20
### 新功能
- CI:新增 skill 发布提交校验 —— 涉及 `skills/<name>/**` 的提交必须使用 Conventional Commit 格式;发布/同步时校验 SKILL.md 版本一致性
### 修复
- `baoyu-diagram`:为 SKILL.md 添加 version 字段
- `baoyu-post-to-wechat`:同步 SKILL.md 版本
### 文档
- `baoyu-wechat-summary`:重构 profile 字段 —— 将 `aliases` 拆分为 `group_nicknames`(用户历史群名)和 `aliases`(其他成员对用户的称呼),新增 `tags` 字段存储横向属性
## 1.117.2 - 2026-05-17
### 文档
- `baoyu-cover-image`:禁止用代码修补已生成的位图文字 —— 不再使用 ImageMagick / Pillow / Canvas / SVG / HTML 叠层覆盖、重写或替换标题/副标题文字,文字异常时应改 prompt 重新生成或换用少字/无标题版本
- `baoyu-article-illustrator``baoyu-comic``baoyu-image-cards``baoyu-xhs-images``baoyu-infographic``baoyu-slide-deck`:同步上述文字修补禁令,各自针对该 skill 的文字类别(标签/说明、对白/拟声词、标题/正文/标签、标题/数据、幻灯片标题/要点)
## 1.117.1 - 2026-05-16
### 修复
+13 -1
View File
@@ -612,8 +612,9 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
| Method | Speed | Requirements |
|--------|-------|--------------|
| API (Recommended) | Fast | API credentials |
| API (Recommended) | Fast | API credentials (local IP allowlisted in WeChat) |
| Browser | Slow | Chrome, login session |
| Remote API | Fast | API credentials + SSH-reachable server whose IP is on WeChat's allowlist |
**API Configuration** (for faster publishing):
@@ -631,6 +632,17 @@ To obtain credentials:
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
**Remote API Method** (for when WeChat's IP allowlist excludes your local machine): tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on the allowlist. No files are written to the remote host and `AppSecret` never leaves the local process. Add to your EXTEND.md:
```yaml
# Optional: only set when WeChat's IP allowlist excludes your local machine
remote_publish_host: server.example.com
remote_publish_user: deploy
remote_publish_identity_file: ~/.ssh/id_ed25519
```
Then publish with `--remote` (or set `default_publish_method: remote-api`). Authentication is SSH key only; only the typed `remote_publish_*` keys are honored.
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
```bash
+4 -1
View File
@@ -612,8 +612,9 @@ clawhub install baoyu-markdown-to-html
| 方式 | 速度 | 要求 |
|------|------|------|
| API(推荐) | 快 | API 凭证 |
| API(推荐) | 快 | API 凭证(本机 IP 在公众号白名单内) |
| 浏览器 | 慢 | Chrome,登录会话 |
| 远程 API | 快 | API 凭证 + 一台 IP 在公众号白名单内、可 SSH 登录的服务器 |
**API 配置**(更快的发布方式):
@@ -631,6 +632,8 @@ WECHAT_APP_SECRET=你的AppSecret
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
**远程 API 方式**(适用于本机 IP 不在公众号白名单内的情况):通过 SSH SOCKS5 动态端口转发,将对 `api.weixin.qq.com` 的 HTTPS 调用转发到 IP 在白名单内的服务器上。Markdown 渲染、图片处理、草稿组装仍在本地完成;远端不会落任何文件,`AppSecret` 不会离开本地进程。仅支持 SSH 密钥认证,且只接受类型化的 `remote_publish_*` 配置项,不透传任意 ssh 选项。在 EXTEND.md 中配置 `remote_publish_host` 等字段后,发布时加上 `--remote`(或将 `default_publish_method` 设为 `remote-api`)。
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
```bash
+4
View File
@@ -23,6 +23,10 @@ bash scripts/sync-clawhub.sh <skill> # sync one skill
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
Every skill release must keep the `version:` in that skill's `SKILL.md` aligned with the version being published. `publish-skill.mjs` and `sync-clawhub.mjs` both reject mismatches so a registry payload cannot ship with stale skill metadata.
Commits that touch `skills/<name>/**` must use Conventional Commit subjects, for example `fix(baoyu-post-to-wechat): handle WeChat editor focus`. CI runs `npm run verify:skill-release-commits` against the pushed or PR commit range so bare subjects like `Fix WeChat browser article publishing` cannot bypass per-skill release versioning silently.
## Shared Workspace Packages
`packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
+2 -1
View File
@@ -7,7 +7,8 @@
],
"scripts": {
"test": "node ./scripts/run-node-tests.mjs",
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage"
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage",
"verify:skill-release-commits": "node ./scripts/verify-skill-release-commits.mjs"
},
"devDependencies": {
"@mozilla/readability": "^0.6.0",
+45
View File
@@ -25,6 +25,37 @@ const MIME_MAP = {
".svg": "image/svg+xml",
};
export async function readSkillMetadataVersion(root) {
const skillFile = await findSkillMarkdown(root);
const source = await fs.readFile(skillFile, "utf8");
const version = readSkillFrontmatterVersion(source);
if (!version) {
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
}
return version;
}
export async function validateSkillMetadataVersion(root, expectedVersion) {
const actualVersion = await readSkillMetadataVersion(root);
if (actualVersion !== expectedVersion) {
throw new Error(
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
);
}
}
export function readSkillFrontmatterVersion(source) {
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
if (!match) return null;
for (const line of match[1].split(/\r?\n/)) {
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
if (versionMatch) return versionMatch[1];
}
return null;
}
export async function listReleaseFiles(root) {
const resolvedRoot = path.resolve(root);
const files = [];
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
return files;
}
async function findSkillMarkdown(root) {
const resolvedRoot = path.resolve(root);
for (const name of ["SKILL.md", "skill.md"]) {
const candidate = path.join(resolvedRoot, name);
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// Try the next supported skill filename.
}
}
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
}
export async function validateSelfContainedRelease(root) {
const resolvedRoot = path.resolve(root);
const files = await listReleaseFiles(root);
+31
View File
@@ -6,6 +6,9 @@ import test from "node:test";
import {
listReleaseFiles,
readSkillFrontmatterVersion,
readSkillMetadataVersion,
validateSkillMetadataVersion,
validateSelfContainedRelease,
} from "./release-files.mjs";
@@ -45,6 +48,34 @@ test("listReleaseFiles skips generated paths and returns sorted relative paths",
);
});
test("readSkillFrontmatterVersion reads quoted and unquoted versions", () => {
assert.equal(readSkillFrontmatterVersion("---\nname: demo\nversion: 1.2.3\n---\n"), "1.2.3");
assert.equal(readSkillFrontmatterVersion("---\nversion: \"2.0.0\"\n---\n"), "2.0.0");
assert.equal(readSkillFrontmatterVersion("# Missing frontmatter\n"), null);
});
test("validateSkillMetadataVersion accepts matching SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
assert.equal(await readSkillMetadataVersion(root), "1.2.3");
await assert.doesNotReject(() => validateSkillMetadataVersion(root, "1.2.3"));
});
test("validateSkillMetadataVersion rejects mismatched SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-mismatch-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
await assert.rejects(
() => validateSkillMetadataVersion(root, "1.2.4"),
/SKILL\.md version mismatch/,
);
});
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
const root = await makeTempDir("baoyu-release-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
+68
View File
@@ -0,0 +1,68 @@
const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//;
const CONVENTIONAL_SUBJECT_PATTERN =
/^(?<type>[a-z][a-z0-9-]*)(?:\((?<scope>[^()\n]+)\))?(?<breaking>!)?: (?<description>\S[\s\S]*)$/;
export function parseConventionalCommitSubject(subject) {
const match = CONVENTIONAL_SUBJECT_PATTERN.exec(subject.trim());
if (!match?.groups) return null;
return {
type: match.groups.type,
scope: match.groups.scope ?? "",
breaking: Boolean(match.groups.breaking),
description: match.groups.description,
};
}
export function changedSkillsForPaths(paths) {
const skills = new Set();
for (const filePath of paths) {
const normalizedPath = filePath.replaceAll("\\", "/");
const match = SKILL_PATH_PATTERN.exec(normalizedPath);
if (match) skills.add(match[1]);
}
return [...skills].sort((left, right) => left.localeCompare(right));
}
export function validateSkillReleaseCommit({ commit = "", subject, paths }) {
const skills = changedSkillsForPaths(paths);
if (skills.length === 0) return [];
const parsed = parseConventionalCommitSubject(subject);
if (parsed) return [];
return [
{
commit,
subject,
skills,
message: `Commit ${formatCommit(commit)} changes ${formatSkills(skills)} but its subject is not a Conventional Commit: ${subject}`,
},
];
}
export function formatSkillReleaseFailures(failures) {
if (failures.length === 0) return "";
const lines = [
"Skill release commit check failed.",
"",
"Commits that touch skills/<name>/** must use Conventional Commit subjects so per-skill release tooling can derive a version bump.",
"Example: fix(baoyu-post-to-wechat): handle WeChat editor focus",
"",
];
for (const failure of failures) {
lines.push(`- ${failure.message}`);
}
return lines.join("\n");
}
function formatCommit(commit) {
return commit ? commit.slice(0, 12) : "<unknown>";
}
function formatSkills(skills) {
return skills.map((skill) => `skills/${skill}/**`).join(", ");
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
changedSkillsForPaths,
formatSkillReleaseFailures,
parseConventionalCommitSubject,
validateSkillReleaseCommit,
} from "./skill-release-guard.mjs";
test("parseConventionalCommitSubject accepts scoped, unscoped, and breaking subjects", () => {
assert.deepEqual(parseConventionalCommitSubject("fix(baoyu-post-to-wechat): repair editor paste"), {
type: "fix",
scope: "baoyu-post-to-wechat",
breaking: false,
description: "repair editor paste",
});
assert.deepEqual(parseConventionalCommitSubject("feat!: change skill metadata format"), {
type: "feat",
scope: "",
breaking: true,
description: "change skill metadata format",
});
assert.equal(parseConventionalCommitSubject("Fix WeChat browser article publishing"), null);
});
test("changedSkillsForPaths returns sorted unique skills", () => {
assert.deepEqual(
changedSkillsForPaths([
"README.md",
"skills/baoyu-post-to-wechat/scripts/wechat-article.ts",
"skills/baoyu-post-to-wechat/SKILL.md",
"skills/baoyu-url-to-markdown/SKILL.md",
]),
["baoyu-post-to-wechat", "baoyu-url-to-markdown"],
);
});
test("validateSkillReleaseCommit ignores non-skill changes", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "Fix test workflow",
paths: [".github/workflows/test.yml"],
}),
[],
);
});
test("validateSkillReleaseCommit rejects non-conventional skill commit subjects", () => {
const failures = validateSkillReleaseCommit({
commit: "81377416b4a7",
subject: "Fix WeChat browser article publishing",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
});
assert.equal(failures.length, 1);
assert.match(failures[0]!.message, /Conventional Commit/);
assert.match(formatSkillReleaseFailures(failures), /fix\(baoyu-post-to-wechat\):/);
});
test("validateSkillReleaseCommit accepts conventional skill commit subjects", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "fix(browser): ensure tab activation before copy/paste in WeChat editor",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
}),
[],
);
});
+7 -1
View File
@@ -5,7 +5,12 @@ import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
import {
listReleaseFiles,
mimeType,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -21,6 +26,7 @@ async function main() {
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
: "";
await validateSkillMetadataVersion(skillDir, options.version);
await validateSelfContainedRelease(skillDir);
const files = await listReleaseFiles(skillDir);
if (files.length === 0) {
+46 -15
View File
@@ -6,7 +6,13 @@ import { existsSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
import {
listReleaseFiles,
mimeType,
readSkillMetadataVersion,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -38,9 +44,11 @@ async function main() {
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
const files = await collectReleaseFiles(skill.folder);
const localVersion = await readSkillMetadataVersion(skill.folder);
const fingerprint = buildFingerprint(files);
return {
...skill,
localVersion,
fileCount: files.length,
fingerprint,
};
@@ -119,12 +127,12 @@ async function main() {
for (const candidate of actionable) {
const version =
candidate.status === "new"
? "1.0.0"
: bumpSemver(candidate.latestVersion, options.bump);
? candidate.localVersion
: resolveUpdateVersion(candidate, options.bump);
console.log(`Publishing ${candidate.slug}@${version}`);
try {
const files = await collectReleaseFiles(candidate.folder);
const files = await collectReleaseFiles(candidate.folder, version);
await publishSkill({
registry,
token: config.token,
@@ -325,7 +333,10 @@ async function hasSkillMarker(folder) {
);
}
async function collectReleaseFiles(root) {
async function collectReleaseFiles(root, expectedVersion = "") {
if (expectedVersion) {
await validateSkillMetadataVersion(root, expectedVersion);
}
await validateSelfContainedRelease(root);
return listReleaseFiles(root);
}
@@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) {
function formatCandidate(candidate, bump) {
if (candidate.status === "new") {
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
return `${candidate.slug} NEW ${candidate.localVersion} (${candidate.fileCount} files)`;
}
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
candidate.latestVersion,
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion(
candidate,
bump
)} (${candidate.fileCount} files)`;
}
function bumpSemver(version, bump) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
if (!match) {
throw new Error(`Invalid semver: ${version}`);
function resolveUpdateVersion(candidate, bump) {
if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) {
return candidate.localVersion;
}
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
return bumpSemver(candidate.latestVersion, bump);
}
function compareSemver(left, right) {
const leftParts = parseSemver(left);
const rightParts = parseSemver(right);
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] - rightParts[index];
}
}
return 0;
}
function bumpSemver(version, bump) {
const [major, minor, patch] = parseSemver(version);
if (bump === "major") return `${major + 1}.0.0`;
if (bump === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function parseSemver(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
if (!match) {
throw new Error(`Invalid semver: ${version}`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function sanitizeSlug(value) {
return value
.trim()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import {
formatSkillReleaseFailures,
validateSkillReleaseCommit,
} from "./lib/skill-release-guard.mjs";
const execFileAsync = promisify(execFile);
const ZERO_SHA = /^0{40}$/;
async function main() {
const options = parseArgs(process.argv.slice(2));
const range = await resolveRange(options);
const commits = await listCommits(range);
const failures = [];
for (const commit of commits) {
const [subject, paths] = await Promise.all([readCommitSubject(commit), readCommitPaths(commit)]);
failures.push(...validateSkillReleaseCommit({ commit, subject, paths }));
}
if (failures.length > 0) {
console.error(formatSkillReleaseFailures(failures));
process.exit(1);
}
console.log(`Skill release commit check passed (${commits.length} commit${commits.length === 1 ? "" : "s"} checked).`);
}
function parseArgs(argv) {
const options = {
base: "",
head: "HEAD",
range: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
options.base = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--head") {
options.head = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--range") {
options.range = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "-h" || arg === "--help") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printUsage() {
console.log(`Usage: verify-skill-release-commits.mjs [--base <rev> --head <rev> | --range <rev-range>]
Checks non-merge commits in the selected range. Any commit that touches
skills/<name>/** must use a Conventional Commit subject, for example:
fix(baoyu-post-to-wechat): handle WeChat editor focus
Without explicit arguments, GitHub Actions event metadata is used when
available. Otherwise the fallback range is HEAD^..HEAD.`);
}
async function resolveRange(options) {
if (options.range) {
return { args: [options.range], label: options.range };
}
if (options.base) {
return {
args: [`${options.base}..${options.head || "HEAD"}`],
label: `${options.base}..${options.head || "HEAD"}`,
};
}
const githubRange = await resolveGitHubRange();
if (githubRange) return githubRange;
return { args: ["HEAD^..HEAD"], label: "HEAD^..HEAD" };
}
async function resolveGitHubRange() {
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!eventPath) return null;
let event = null;
try {
event = JSON.parse(await fs.readFile(eventPath, "utf8"));
} catch {
return null;
}
if (event?.pull_request?.base?.sha) {
const base = event.pull_request.base.sha;
const head = process.env.GITHUB_SHA || "HEAD";
return { args: [`${base}..${head}`], label: `${base}..${head}` };
}
if (event?.before && !ZERO_SHA.test(event.before)) {
const head = event.after || process.env.GITHUB_SHA || "HEAD";
return { args: [`${event.before}..${head}`], label: `${event.before}..${head}` };
}
return null;
}
async function listCommits(range) {
const output = await git(["rev-list", "--no-merges", "--reverse", ...range.args]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function readCommitSubject(commit) {
return git(["log", "-1", "--format=%s", commit]);
}
async function readCommitPaths(commit) {
const output = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function git(args) {
const { stdout } = await execFileAsync("git", args, {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return stdout.trimEnd();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace labels, captions, or any other text inside an already generated illustration. If text is wrong or unclear, regenerate from a corrected prompt, redraw with less or no on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -233,6 +235,12 @@ When input is **pasted content** (no file path), always uses `illustrations/{top
| Add | Position → Prompt → Generate → Update outline → Insert |
| Delete | Delete files → Remove reference → Update outline |
Text correction policy:
- If any rendered text (labels, captions, etc.) is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## References
| File | Content |
+8
View File
@@ -40,6 +40,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace dialogue, sound effects, panel labels, or any other text inside an already generated comic page. If text is wrong or unclear, regenerate from a corrected prompt, redraw the page with less or no on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -313,6 +315,12 @@ If EXTEND.md is not found, first-time setup is **blocking** — complete it befo
**IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
Text correction policy:
- If dialogue, sound effects, panel labels, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## Notes
- Image generation: 10-30 seconds per page
+8
View File
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace title/subtitle text inside an already generated cover image. If text is wrong or unclear, regenerate from a corrected prompt, switch to a lower-text or no-title variant, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -241,6 +243,12 @@ Files:
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
Text correction policy:
- If the title/subtitle is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## Composition Principles
- **Whitespace**: 40-60% breathing room
+1
View File
@@ -1,6 +1,7 @@
---
name: baoyu-diagram
description: Create professional, dark-themed SVG diagrams of any type — architecture diagrams, flowcharts, sequence diagrams, structural diagrams, mind maps, timelines, illustrative/conceptual diagrams, and more. Use this skill whenever the user asks for any kind of technical or conceptual diagram, visualization of a system, process flow, data flow, component relationship, network topology, decision tree, org chart, state machine, or any visual representation of structure/logic/process. Also trigger when the user says "画个图" "画一个架构图" "diagram" "flowchart" "sequence diagram" "draw me a ..." or uploads content and asks to visualize it. Output is always a standalone .svg file.
version: 1.117.3
---
# Diagram Generator
+8
View File
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -431,6 +433,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
Text correction policy:
- If a card's title, body copy, tags, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## References
| File | Content |
+8
View File
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace labels, headings, callouts, data values, or any other text inside an already generated infographic. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -297,6 +299,12 @@ Combine:
4. Call the chosen backend with the prompt file and output path
5. On failure, auto-retry once
Text correction policy:
- If labels, headings, callouts, data values, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
### Step 7: Output Summary
Report: topic, layout, style, aspect, language, image backend, output path, files created.
+45 -16
View File
@@ -1,7 +1,7 @@
---
name: baoyu-post-to-wechat
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
version: 1.56.1
version: 1.117.5
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
@@ -64,13 +64,26 @@ Found → read, parse, apply. Not found → run first-time setup (`references/co
```md
default_theme: default
default_color: blue
default_publish_method: api
default_publish_method: browser
default_author: 宝玉
need_open_comment: 1
only_fans_can_comment: 0
chrome_profile_path: /path/to/chrome/profile
# Remote API publishing (optional) — only set if WeChat's IP allowlist
# excludes your local machine. See "Remote API Method" below.
# remote_publish_host: server.example.com
# remote_publish_user: deploy
# remote_publish_port: 22
# remote_publish_identity_file: ~/.ssh/id_ed25519
# remote_publish_known_hosts_file: ~/.ssh/known_hosts
# remote_publish_strict_host_key_checking: accept-new
# remote_publish_connect_timeout: 10
# remote_publish_proxy_jump: bastion.example.com
```
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only (no passwords).
**Theme options**: default, grace, simple, modern. **Color presets**: blue, green, vermilion, yellow, purple, sky, rose, olive, black, gray, pink, red, orange (or hex).
**Value priority**: CLI args → frontmatter → EXTEND.md (account-level → global) → skill defaults.
@@ -148,11 +161,14 @@ Ask method unless specified in EXTEND.md or CLI:
| Method | Speed | Requires |
|--------|-------|----------|
| `api` (Recommended) | Fast | API credentials |
| `api` (Recommended) | Fast | API credentials (local IP allowlisted) |
| `browser` | Slow | Chrome + logged-in session |
| `remote-api` | Fast | API credentials + an SSH-reachable server whose IP is on the WeChat allowlist |
**API selected + missing credentials** → run guided setup per `references/api-setup.md` (writes to `.baoyu-skills/.env`).
**`remote-api` method**: WeChat's "公众号设置 → IP 白名单" often limits API access to one or two fixed IPs. If your local machine's IP is not on that list but a cloud server's is, use `remote-api`: all markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally, and only the outbound HTTPS calls to `api.weixin.qq.com` (token, uploadimg, add_material, draft/add) are tunneled through an SSH SOCKS5 dynamic port forward (`ssh -N -D`) so that WeChat sees the remote server as the source IP. No files are written to the remote host; `AppSecret` never leaves the local process. Requires only `sshd` and outbound network on the remote host — no Python, no agent process. See "Remote API Method" below.
### Step 3: Resolve Theme/Color and Validate Metadata
1. **Theme**: CLI `--theme` → EXTEND.md `default_theme``default` (first match wins; do NOT ask if resolved).
@@ -183,6 +199,14 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> [--color <color>
Always pass `--theme` even if it's `default`. Only pass `--color` when explicitly set by the user or EXTEND.md.
**Remote API method** (same script, adds `--remote`):
```bash
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> --remote [--remote-host <host>] [--remote-user <user>] [--remote-port <port>] [--remote-identity-file <path>] [--remote-known-hosts-file <path>] [--remote-strict-host-key-checking yes|no|accept-new] [--remote-connect-timeout <s>] [--remote-proxy-jump <spec>]
```
Any `--remote-*` flag implies `--remote`. CLI values override account-level then global `remote_publish_*` keys from EXTEND.md. Setting `default_publish_method: remote-api` also enables remote mode without `--remote`.
**`draft/add` payload rules**:
- Endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN`
- `article_type`: `news` (default) or `newspic`
@@ -225,19 +249,20 @@ Files created:
## Feature Comparison
| Feature | Image-Text | Article (API) | Article (Browser) |
|---------|:---:|:---:|:---:|
| Plain text input | ✗ | ✓ | ✓ |
| HTML input | ✗ | ✓ | ✓ |
| Markdown input | Title/content | ✓ | ✓ |
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
| Themes | ✗ | ✓ | ✓ |
| Auto-generate metadata | ✗ | ✓ | ✓ |
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ |
| Comment control | ✗ | ✓ | ✗ |
| Requires Chrome | ✓ | ✗ | ✓ |
| Requires API credentials | ✗ | ✓ | ✗ |
| Speed | Medium | Fast | Slow |
| Feature | Image-Text | Article (API) | Article (Remote API) | Article (Browser) |
|---------|:---:|:---:|:---:|:---:|
| Plain text input | ✗ | ✓ | ✓ | ✓ |
| HTML input | ✗ | ✓ | ✓ | ✓ |
| Markdown input | Title/content | ✓ | ✓ | ✓ |
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) | ✓ (inline) |
| Themes | ✗ | ✓ | ✓ | ✓ |
| Auto-generate metadata | ✗ | ✓ | ✓ | ✓ |
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✓ | ✗ |
| Comment control | ✗ | ✓ | ✓ | ✗ |
| Requires Chrome | ✓ | ✗ | ✗ | ✓ |
| Requires API credentials | ✗ | ✓ | ✓ | ✗ |
| Requires SSH-reachable server with allowlisted IP | ✗ | ✗ | ✓ | ✗ |
| Speed | Medium | Fast | Fast | Slow |
## Troubleshooting
@@ -251,6 +276,10 @@ Files created:
| No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory |
| Wrong comment defaults | Check `need_open_comment` / `only_fans_can_comment` in EXTEND.md |
| Paste fails | Check system clipboard permissions |
| `Remote publish host is required` | Set `--remote-host` or `remote_publish_host` in EXTEND.md |
| `SOCKS proxy on 127.0.0.1:… not ready` | SSH could not start the tunnel — check key, host, `StrictHostKeyChecking`, or use `--remote-connect-timeout` |
| `ssh exited early` during remote publish | Verify the user can `ssh` non-interactively to the server; raise `--remote-connect-timeout` if the link is slow |
| Remote API call returns `errcode 40164` (invalid IP) | The remote server's egress IP is not on WeChat's allowlist; add it in 公众号设置 → IP 白名单 |
## References
@@ -86,8 +86,12 @@ options:
description: "Fast, requires API credentials (AppID + AppSecret)"
- label: "browser"
description: "Slow, requires Chrome and login session"
- label: "remote-api"
description: "Fast, tunnels WeChat API calls through SSH to a server whose IP is on the WeChat allowlist"
```
If the user selects `remote-api`, prompt for `remote_publish_host` and (optionally) `remote_publish_user`, `remote_publish_identity_file`. These can also be filled in later by editing EXTEND.md.
### Question 4: Default Author
```yaml
@@ -157,13 +161,26 @@ options:
```md
default_theme: [default/grace/simple/modern]
default_color: [preset name, hex, or empty for theme default]
default_publish_method: [api/browser]
default_publish_method: [api/browser/remote-api]
default_author: [author name or empty]
need_open_comment: [1/0]
only_fans_can_comment: [1/0]
chrome_profile_path:
# Remote API publishing — only fill in if default_publish_method is remote-api
# or you plan to pass --remote on the CLI.
remote_publish_host:
remote_publish_user:
remote_publish_port:
remote_publish_identity_file:
remote_publish_known_hosts_file:
remote_publish_strict_host_key_checking:
remote_publish_connect_timeout:
remote_publish_proxy_jump:
```
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only.
### Multi-Account
```md
@@ -174,15 +191,19 @@ accounts:
- name: [display name]
alias: [short key, e.g. "baoyu"]
default: true
default_publish_method: [api/browser]
default_publish_method: [api/browser/remote-api]
default_author: [author name]
need_open_comment: [1/0]
only_fans_can_comment: [1/0]
app_id: [WeChat App ID, optional]
app_secret: [WeChat App Secret, optional]
# Remote API publishing (optional, per-account override of globals)
remote_publish_host:
remote_publish_user:
remote_publish_identity_file:
- name: [second account name]
alias: [short key, e.g. "ai-tools"]
default_publish_method: [api/browser]
default_publish_method: [api/browser/remote-api]
default_author: [author name]
need_open_comment: [1/0]
only_fans_can_comment: [1/0]
@@ -37,7 +37,7 @@ accounts:
## Per-Account vs Global Keys
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`.
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`, `remote_publish_host`, `remote_publish_user`, `remote_publish_port`, `remote_publish_identity_file`, `remote_publish_known_hosts_file`, `remote_publish_strict_host_key_checking`, `remote_publish_connect_timeout`, `remote_publish_proxy_jump`.
**Global-only** (always shared): `default_theme`, `default_color`.
@@ -99,3 +99,54 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-too
${BUN_X} {baseDir}/scripts/wechat-article.ts --markdown <file> --theme default --account baoyu
${BUN_X} {baseDir}/scripts/wechat-browser.ts --markdown <file> --images ./photos/ --account baoyu
```
## Remote API Publishing
`wechat-api.ts` supports a `remote-api` mode that tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on WeChat's allowlist. Markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally; only outbound HTTPS calls to `api.weixin.qq.com` traverse the tunnel. No files are written to the remote host and `AppSecret` never leaves the local process. The remote host needs only `sshd` and outbound network access.
### Per-Account Configuration
```md
default_theme: default
default_color: blue
default_publish_method: browser # browser remains the default
accounts:
- name: 宝玉的技术分享
alias: baoyu
default: true
default_publish_method: api
default_author: 宝玉
app_id: your_wechat_app_id
app_secret: your_wechat_app_secret
- name: AI工具集
alias: ai-tools
default_publish_method: remote-api
default_author: AI工具集
app_id: your_ai_tools_app_id
app_secret: your_ai_tools_app_secret
remote_publish_host: ai-tools-server.example.com
remote_publish_user: deploy
remote_publish_port: 22
remote_publish_identity_file: /home/me/.ssh/id_ed25519
remote_publish_known_hosts_file: /home/me/.ssh/known_hosts
remote_publish_strict_host_key_checking: accept-new
```
Account-level `remote_publish_*` values override top-level globals. CLI `--remote-*` flags override both.
### CLI Usage
```bash
# Use the account's default_publish_method (remote-api here):
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-tools
# Force remote mode regardless of default_publish_method:
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account baoyu --remote --remote-host other-server.example.com
```
### Security Notes
- Authentication is SSH key only. Passwords and `ssh-askpass` are not used.
- Only the typed `remote_publish_*` keys are read; raw `ssh` / `scp` options are intentionally not supported.
- The tunnel forwards raw TCP; TLS verification for `api.weixin.qq.com` is still performed end-to-end by the local process.
@@ -9,6 +9,7 @@
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0",
"jimp": "^1.6.0",
"socks": "^2.8.9",
},
},
},
@@ -165,6 +166,8 @@
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="],
@@ -277,6 +280,10 @@
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
@@ -6,6 +6,7 @@
"@jsquash/webp": "^1.5.0",
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0",
"jimp": "^1.6.0"
"jimp": "^1.6.0",
"socks": "^2.8.9"
}
}
+197 -171
View File
@@ -2,13 +2,25 @@ import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
import {
loadWechatExtendConfig,
resolveAccount,
loadCredentials,
type ResolvedAccount,
type StrictHostKeyChecking,
} from "./wechat-extend-config.ts";
import {
type WechatUploadAsset,
prepareWechatBodyImageUpload,
needsWechatBodyImageProcessing,
detectImageFormatFromBuffer,
} from "./wechat-image-processor.ts";
import { loadUploadAsset } from "./wechat-image-loader.ts";
import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts";
import {
type RemotePublishConfig,
normalizeRemoteConfig,
withSshTunnel,
} from "./wechat-remote-publish.ts";
interface AccessTokenResponse {
access_token?: string;
@@ -62,13 +74,17 @@ 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): 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 fetch(url);
if (!res.ok) {
const res = await client(url);
if (res.status < 200 || res.status >= 300) {
throw new Error(`Failed to fetch access token: ${res.status}`);
}
const data = await res.json() as AccessTokenResponse;
const data = await res.json<AccessTokenResponse>();
if (data.errcode) {
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
}
@@ -83,86 +99,12 @@ function toHttpsUrl(url: string | undefined): string {
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
}
async function loadUploadAsset(
imagePath: string,
baseDir?: string,
): Promise<WechatUploadAsset> {
let fileBuffer: Buffer;
let filename: string;
let contentType: string;
let fileSize = 0;
let fileExt = "";
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to download image: ${imagePath}`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error(`Remote image is empty: ${imagePath}`);
}
fileBuffer = Buffer.from(buffer);
fileSize = buffer.byteLength;
const urlPath = imagePath.split("?")[0];
filename = path.basename(urlPath) || "image.jpg";
fileExt = path.extname(filename).toLowerCase();
contentType = response.headers.get("content-type") || "image/jpeg";
} else {
const resolvedPath = path.isAbsolute(imagePath)
? imagePath
: path.resolve(baseDir || process.cwd(), imagePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Image not found: ${resolvedPath}`);
}
const stats = fs.statSync(resolvedPath);
if (stats.size === 0) {
throw new Error(`Local image is empty: ${resolvedPath}`);
}
fileSize = stats.size;
fileBuffer = fs.readFileSync(resolvedPath);
filename = path.basename(resolvedPath);
fileExt = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff",
".tif": "image/tiff",
".svg": "image/svg+xml",
".ico": "image/x-icon",
};
contentType = mimeTypes[fileExt] || "image/jpeg";
}
// Detect actual format from magic bytes to fix extension/content-type mismatches
// (e.g. CDNs serving WebP for URLs with .png extension)
const detected = detectImageFormatFromBuffer(fileBuffer);
if (detected && detected.contentType !== contentType) {
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
contentType = detected.contentType;
fileExt = detected.fileExt;
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
}
return {
buffer: fileBuffer,
filename,
contentType,
fileExt,
fileSize,
};
}
async function uploadImage(
imagePath: string,
accessToken: string,
baseDir?: string,
uploadType: "body" | "material" = "body"
uploadType: "body" | "material" = "body",
client: WechatClient = wechatHttp,
): Promise<UploadResponse> {
const asset = await loadUploadAsset(imagePath, baseDir);
let uploadAsset = asset;
@@ -187,6 +129,7 @@ async function uploadImage(
uploadAsset.contentType,
accessToken,
uploadType,
client,
);
// media/uploadimg 接口只返回 URLmaterial/add_material 返回 media_id
@@ -201,39 +144,27 @@ async function uploadImage(
}
}
// 实际的微信上传函数
async function uploadToWechat(
fileBuffer: Buffer,
filename: string,
contentType: string,
accessToken: string,
uploadType: "body" | "material"
uploadType: "body" | "material",
client: WechatClient = wechatHttp,
): Promise<UploadResponse> {
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
const header = [
`--${boundary}`,
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
`Content-Type: ${contentType}`,
"",
"",
].join("\r\n");
const footer = `\r\n--${boundary}--\r\n`;
const headerBuffer = Buffer.from(header, "utf-8");
const footerBuffer = Buffer.from(footer, "utf-8");
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
const multipart = buildMultipart([
{ name: "media", filename, contentType, data: fileBuffer },
]);
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
const res = await fetch(url, {
const res = await client(url, {
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body,
headers: { "Content-Type": multipart.contentType },
body: multipart.body,
});
const data = await res.json() as UploadResponse;
const data = await res.json<UploadResponse>();
if (data.errcode && data.errcode !== 0) {
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
}
@@ -248,6 +179,7 @@ async function uploadImagesInHtml(
contentImages: ImageInfo[] = [],
articleType: ArticleType = "news",
collectNewsCoverFallback: boolean = false,
client: WechatClient = wechatHttp,
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
const matches = [...html.matchAll(imgRegex)];
@@ -268,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");
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);
@@ -284,8 +216,7 @@ async function uploadImagesInHtml(
try {
let resp = uploadedBySource.get(imagePath);
if (!resp) {
// 正文图片使用 media/uploadimg 接口获取 URL
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
uploadedBySource.set(imagePath, resp);
}
const newTag = fullTag
@@ -296,7 +227,7 @@ async function uploadImagesInHtml(
if (shouldUploadMaterial) {
let materialResp = uploadedBySource.get(`${imagePath}:material`);
if (!materialResp) {
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
uploadedBySource.set(`${imagePath}:material`, materialResp);
}
if (articleType === "newspic" && materialResp.media_id) {
@@ -320,8 +251,7 @@ async function uploadImagesInHtml(
try {
let resp = uploadedBySource.get(imagePath);
if (!resp) {
// 正文图片使用 media/uploadimg 接口获取 URL
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
uploadedBySource.set(imagePath, resp);
}
@@ -331,7 +261,7 @@ async function uploadImagesInHtml(
if (shouldUploadMaterial) {
let materialResp = uploadedBySource.get(`${imagePath}:material`);
if (!materialResp) {
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
uploadedBySource.set(`${imagePath}:material`, materialResp);
}
if (articleType === "newspic" && materialResp.media_id) {
@@ -351,7 +281,8 @@ async function uploadImagesInHtml(
async function publishToDraft(
options: ArticleOptions,
accessToken: string
accessToken: string,
client: WechatClient = wechatHttp,
): Promise<PublishResponse> {
const url = `${DRAFT_URL}?access_token=${accessToken}`;
@@ -388,15 +319,13 @@ async function publishToDraft(
if (options.digest) article.digest = options.digest;
}
const res = await fetch(url, {
const res = await client(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ articles: [article] }),
});
const data = await res.json() as PublishResponse;
const data = await res.json<PublishResponse>();
if (data.errcode && data.errcode !== 0) {
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
}
@@ -494,6 +423,15 @@ Options:
--account <alias> Select account by alias (for multi-account setups)
--no-cite Disable bottom citations for ordinary external links in markdown mode
--dry-run Parse and render only, don't publish
--remote Route WeChat API calls via SSH SOCKS5 tunnel to a whitelisted server
--remote-host <h> Remote server host (implies --remote)
--remote-user <u> SSH user (default: root, implies --remote)
--remote-port <n> SSH port (default: 22, implies --remote)
--remote-identity-file <p> SSH private key path (implies --remote)
--remote-known-hosts-file <p> SSH known_hosts file path (implies --remote)
--remote-strict-host-key-checking <yes|no|accept-new> (implies --remote)
--remote-connect-timeout <seconds> SSH ConnectTimeout (implies --remote)
--remote-proxy-jump <spec> SSH ProxyJump value (implies --remote)
--help Show this help
Frontmatter Fields (markdown):
@@ -539,6 +477,15 @@ interface CliArgs {
account?: string;
citeStatus: boolean;
dryRun: boolean;
remote: boolean;
remoteHost?: string;
remoteUser?: string;
remotePort?: number;
remoteIdentityFile?: string;
remoteKnownHostsFile?: string;
remoteStrictHostKeyChecking?: StrictHostKeyChecking;
remoteConnectTimeout?: number;
remoteProxyJump?: string;
}
function parseArgs(argv: string[]): CliArgs {
@@ -553,6 +500,7 @@ function parseArgs(argv: string[]): CliArgs {
theme: "default",
citeStatus: true,
dryRun: false,
remote: false,
};
for (let i = 0; i < argv.length; i++) {
@@ -582,6 +530,47 @@ function parseArgs(argv: string[]): CliArgs {
args.citeStatus = false;
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg === "--remote") {
args.remote = true;
} else if (arg === "--remote-host" && argv[i + 1]) {
args.remoteHost = argv[++i];
args.remote = true;
} else if (arg === "--remote-user" && argv[i + 1]) {
args.remoteUser = argv[++i];
args.remote = true;
} else if (arg === "--remote-port" && argv[i + 1]) {
const n = Number.parseInt(argv[++i]!, 10);
if (!Number.isInteger(n) || n < 1 || n > 65535) {
console.error(`Error: --remote-port must be 1-65535, got ${argv[i]}`);
process.exit(1);
}
args.remotePort = n;
args.remote = true;
} else if (arg === "--remote-identity-file" && argv[i + 1]) {
args.remoteIdentityFile = argv[++i];
args.remote = true;
} else if (arg === "--remote-known-hosts-file" && argv[i + 1]) {
args.remoteKnownHostsFile = argv[++i];
args.remote = true;
} else if (arg === "--remote-strict-host-key-checking" && argv[i + 1]) {
const v = argv[++i]!.toLowerCase();
if (v !== "yes" && v !== "no" && v !== "accept-new") {
console.error(`Error: --remote-strict-host-key-checking must be yes|no|accept-new, got ${argv[i]}`);
process.exit(1);
}
args.remoteStrictHostKeyChecking = v as StrictHostKeyChecking;
args.remote = true;
} else if (arg === "--remote-connect-timeout" && argv[i + 1]) {
const n = Number.parseInt(argv[++i]!, 10);
if (!Number.isInteger(n) || n <= 0) {
console.error(`Error: --remote-connect-timeout must be a positive integer, got ${argv[i]}`);
process.exit(1);
}
args.remoteConnectTimeout = n;
args.remote = true;
} else if (arg === "--remote-proxy-jump" && argv[i + 1]) {
args.remoteProxyJump = argv[++i];
args.remote = true;
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
i++;
} else if (!arg.startsWith("-")) {
@@ -607,6 +596,27 @@ function extractHtmlTitle(html: string): string {
return "";
}
function buildRemoteConfig(args: CliArgs, resolved: ResolvedAccount): RemotePublishConfig {
const host = args.remoteHost ?? resolved.remote_publish_host;
if (!host) {
throw new Error(
"Remote publishing requires a host. Set --remote-host, EXTEND.md remote_publish_host, " +
"or an account-level remote_publish_host.",
);
}
return {
host,
user: args.remoteUser ?? resolved.remote_publish_user,
port: args.remotePort ?? resolved.remote_publish_port,
identityFile: args.remoteIdentityFile ?? resolved.remote_publish_identity_file,
knownHostsFile: args.remoteKnownHostsFile ?? resolved.remote_publish_known_hosts_file,
strictHostKeyChecking:
args.remoteStrictHostKeyChecking ?? resolved.remote_publish_strict_host_key_checking,
connectTimeout: args.remoteConnectTimeout ?? resolved.remote_publish_connect_timeout,
proxyJump: args.remoteProxyJump ?? resolved.remote_publish_proxy_jump,
};
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
@@ -709,8 +719,6 @@ async function main(): Promise<void> {
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
}
console.error(`[wechat-api] Credentials source: ${creds.source}`);
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
const rawCoverPath = args.cover ||
frontmatter.coverImage ||
@@ -722,62 +730,80 @@ async function main(): Promise<void> {
: rawCoverPath;
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
console.error("[wechat-api] Uploading body images...");
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir,
contentImages,
args.articleType,
needNewsCoverFallback,
);
htmlContent = processedHtml;
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
const method = useRemote ? "remote-api" : "api";
let thumbMediaId = "";
const publishWith = async (client: WechatClient): Promise<void> => {
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);
if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
// 封面图片使用 material/add_material 接口
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
thumbMediaId = coverResp.media_id;
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
} else if (firstCoverMediaId && args.articleType === "news") {
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
thumbMediaId = firstCoverMediaId;
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
console.error("[wechat-api] Uploading body images...");
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir,
contentImages,
args.articleType,
needNewsCoverFallback,
client,
);
htmlContent = processedHtml;
let thumbMediaId = "";
if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
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") {
thumbMediaId = firstCoverMediaId;
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
}
if (args.articleType === "news" && !thumbMediaId) {
throw new Error("No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
}
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
throw new Error("newspic requires at least one image in content.");
}
console.error("[wechat-api] Publishing to draft...");
const result = await publishToDraft({
title,
author: author || undefined,
digest: digest || undefined,
content: htmlContent,
thumbMediaId,
articleType: args.articleType,
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
needOpenComment: resolved.need_open_comment,
onlyFansCanComment: resolved.only_fans_can_comment,
}, accessToken, client);
console.log(JSON.stringify({
success: true,
media_id: result.media_id,
title,
articleType: args.articleType,
method,
}, null, 2));
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
};
if (useRemote) {
const remoteConfig = normalizeRemoteConfig(buildRemoteConfig(args, resolved));
console.error(
`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,
);
await withSshTunnel(remoteConfig, async (client) => {
await publishWith(client);
});
} else {
await publishWith(wechatHttp);
}
if (args.articleType === "news" && !thumbMediaId) {
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
process.exit(1);
}
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
console.error("Error: newspic requires at least one image in content.");
process.exit(1);
}
console.error("[wechat-api] Publishing to draft...");
const result = await publishToDraft({
title,
author: author || undefined,
digest: digest || undefined,
content: htmlContent,
thumbMediaId,
articleType: args.articleType,
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
needOpenComment: resolved.need_open_comment,
onlyFansCanComment: resolved.only_fans_can_comment,
}, accessToken);
console.log(JSON.stringify({
success: true,
media_id: result.media_id,
title,
articleType: args.articleType,
}, null, 2));
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
}
await main().catch((err) => {
@@ -5,7 +5,11 @@ import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import { loadCredentials } from "./wechat-extend-config.ts";
import {
loadCredentials,
loadWechatExtendConfig,
resolveAccount,
} from "./wechat-extend-config.ts";
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
@@ -73,6 +77,28 @@ async function writeEnvFile(root: string, content: string): Promise<void> {
await fs.writeFile(envPath, content);
}
async function writeExtendFile(root: string, content: string): Promise<void> {
const extendPath = path.join(root, ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md");
await fs.mkdir(path.dirname(extendPath), { recursive: true });
await fs.writeFile(extendPath, content);
}
function useXdgConfigHome(t: TestContext, value: string | undefined): void {
const previous = process.env.XDG_CONFIG_HOME;
if (value === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = value;
}
t.after(() => {
if (previous === undefined) {
delete process.env.XDG_CONFIG_HOME;
return;
}
process.env.XDG_CONFIG_HOME = previous;
});
}
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
@@ -119,6 +145,149 @@ test("loadCredentials prefers a complete process.env pair over lower-priority fi
assert.deepEqual(credentials.skippedSources, []);
});
test("resolveAccount returns global remote_publish_* values when no account is configured", async (t) => {
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
const homeRoot = await makeTempDir("wechat-extend-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useXdgConfigHome(t, undefined);
await writeExtendFile(
cwdRoot,
[
"default_publish_method: remote-api",
"remote_publish_host: bastion.example.com",
"remote_publish_user: deploy",
"remote_publish_port: 2222",
"remote_publish_identity_file: /home/me/.ssh/id_ed25519",
"remote_publish_known_hosts_file: /home/me/.ssh/known_hosts",
"remote_publish_strict_host_key_checking: accept-new",
"remote_publish_connect_timeout: 12",
"remote_publish_proxy_jump: jump.example.com",
].join("\n"),
);
const config = loadWechatExtendConfig();
const resolved = resolveAccount(config);
assert.equal(resolved.default_publish_method, "remote-api");
assert.equal(resolved.remote_publish_host, "bastion.example.com");
assert.equal(resolved.remote_publish_user, "deploy");
assert.equal(resolved.remote_publish_port, 2222);
assert.equal(resolved.remote_publish_identity_file, "/home/me/.ssh/id_ed25519");
assert.equal(resolved.remote_publish_known_hosts_file, "/home/me/.ssh/known_hosts");
assert.equal(resolved.remote_publish_strict_host_key_checking, "accept-new");
assert.equal(resolved.remote_publish_connect_timeout, 12);
assert.equal(resolved.remote_publish_proxy_jump, "jump.example.com");
});
test("resolveAccount lets account-level remote_publish_* override globals", async (t) => {
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
const homeRoot = await makeTempDir("wechat-extend-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useXdgConfigHome(t, undefined);
await writeExtendFile(
cwdRoot,
[
"default_publish_method: browser",
"remote_publish_host: global.example.com",
"remote_publish_user: deploy",
"remote_publish_port: 22",
"accounts:",
" - name: Primary",
" alias: primary",
" default: true",
" remote_publish_host: primary.example.com",
" remote_publish_user: primary-user",
" remote_publish_port: 2200",
" remote_publish_identity_file: /p/id_primary",
" - name: Secondary",
" alias: secondary",
" remote_publish_proxy_jump: jump.example.com",
].join("\n"),
);
const config = loadWechatExtendConfig();
const primary = resolveAccount(config, "primary");
assert.equal(primary.alias, "primary");
assert.equal(primary.remote_publish_host, "primary.example.com");
assert.equal(primary.remote_publish_user, "primary-user");
assert.equal(primary.remote_publish_port, 2200);
assert.equal(primary.remote_publish_identity_file, "/p/id_primary");
assert.equal(primary.remote_publish_proxy_jump, undefined);
const secondary = resolveAccount(config, "secondary");
assert.equal(secondary.alias, "secondary");
assert.equal(secondary.remote_publish_host, "global.example.com");
assert.equal(secondary.remote_publish_user, "deploy");
assert.equal(secondary.remote_publish_port, 22);
assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com");
});
test("loadWechatExtendConfig throws on invalid remote_publish_port", async (t) => {
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
const homeRoot = await makeTempDir("wechat-extend-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useXdgConfigHome(t, undefined);
await writeExtendFile(
cwdRoot,
[
"remote_publish_host: example.com",
"remote_publish_port: 99999",
].join("\n"),
);
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_port: 99999/);
});
test("loadWechatExtendConfig throws on invalid remote_publish_connect_timeout", async (t) => {
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
const homeRoot = await makeTempDir("wechat-extend-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useXdgConfigHome(t, undefined);
await writeExtendFile(
cwdRoot,
[
"remote_publish_host: example.com",
"remote_publish_connect_timeout: 0",
].join("\n"),
);
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_connect_timeout: 0/);
});
test("loadWechatExtendConfig throws on invalid remote_publish_strict_host_key_checking", async (t) => {
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
const homeRoot = await makeTempDir("wechat-extend-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useXdgConfigHome(t, undefined);
await writeExtendFile(
cwdRoot,
[
"remote_publish_host: example.com",
"remote_publish_strict_host_key_checking: maybe",
].join("\n"),
);
assert.throws(
() => loadWechatExtendConfig(),
/Invalid remote_publish_strict_host_key_checking: maybe/,
);
});
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
@@ -2,6 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import os from "node:os";
export type StrictHostKeyChecking = "yes" | "no" | "accept-new";
export interface WechatAccount {
name: string;
alias: string;
@@ -13,6 +15,14 @@ export interface WechatAccount {
app_id?: string;
app_secret?: string;
chrome_profile_path?: string;
remote_publish_host?: string;
remote_publish_user?: string;
remote_publish_port?: number;
remote_publish_identity_file?: string;
remote_publish_known_hosts_file?: string;
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
remote_publish_connect_timeout?: number;
remote_publish_proxy_jump?: string;
}
export interface WechatExtendConfig {
@@ -23,6 +33,14 @@ export interface WechatExtendConfig {
need_open_comment?: number;
only_fans_can_comment?: number;
chrome_profile_path?: string;
remote_publish_host?: string;
remote_publish_user?: string;
remote_publish_port?: number;
remote_publish_identity_file?: string;
remote_publish_known_hosts_file?: string;
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
remote_publish_connect_timeout?: number;
remote_publish_proxy_jump?: string;
accounts?: WechatAccount[];
}
@@ -36,6 +54,14 @@ export interface ResolvedAccount {
app_id?: string;
app_secret?: string;
chrome_profile_path?: string;
remote_publish_host?: string;
remote_publish_user?: string;
remote_publish_port?: number;
remote_publish_identity_file?: string;
remote_publish_known_hosts_file?: string;
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
remote_publish_connect_timeout?: number;
remote_publish_proxy_jump?: string;
}
function stripQuotes(s: string): string {
@@ -46,6 +72,34 @@ function toBool01(v: string): number {
return v === "1" || v === "true" ? 1 : 0;
}
function homeDir(): string {
return process.env.HOME || process.env.USERPROFILE || os.homedir();
}
function parsePort(key: string, v: string): number {
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 65535) {
throw new Error(`Invalid ${key}: ${v} (expected integer 1-65535)`);
}
return n;
}
function parsePositiveInt(key: string, v: string): number {
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n) || String(n) !== v.trim() || n <= 0) {
throw new Error(`Invalid ${key}: ${v} (expected positive integer)`);
}
return n;
}
function parseStrictHostKeyChecking(key: string, v: string): StrictHostKeyChecking {
const lower = v.toLowerCase();
if (lower === "yes" || lower === "no" || lower === "accept-new") {
return lower;
}
throw new Error(`Invalid ${key}: ${v} (expected yes|no|accept-new)`);
}
function parseWechatExtend(content: string): WechatExtendConfig {
const config: WechatExtendConfig = {};
const lines = content.split("\n");
@@ -106,6 +160,14 @@ function parseWechatExtend(content: string): WechatExtendConfig {
case "need_open_comment": config.need_open_comment = toBool01(val); break;
case "only_fans_can_comment": config.only_fans_can_comment = toBool01(val); break;
case "chrome_profile_path": config.chrome_profile_path = val; break;
case "remote_publish_host": config.remote_publish_host = val; break;
case "remote_publish_user": config.remote_publish_user = val; break;
case "remote_publish_port": config.remote_publish_port = parsePort("remote_publish_port", val); break;
case "remote_publish_identity_file": config.remote_publish_identity_file = val; break;
case "remote_publish_known_hosts_file": config.remote_publish_known_hosts_file = val; break;
case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", val); break;
case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = parsePositiveInt("remote_publish_connect_timeout", val); break;
case "remote_publish_proxy_jump": config.remote_publish_proxy_jump = val; break;
}
}
@@ -123,6 +185,18 @@ function parseWechatExtend(content: string): WechatExtendConfig {
app_id: a.app_id || undefined,
app_secret: a.app_secret || undefined,
chrome_profile_path: a.chrome_profile_path || undefined,
remote_publish_host: a.remote_publish_host || undefined,
remote_publish_user: a.remote_publish_user || undefined,
remote_publish_port: a.remote_publish_port ? parsePort("remote_publish_port", a.remote_publish_port) : undefined,
remote_publish_identity_file: a.remote_publish_identity_file || undefined,
remote_publish_known_hosts_file: a.remote_publish_known_hosts_file || undefined,
remote_publish_strict_host_key_checking: a.remote_publish_strict_host_key_checking
? parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", a.remote_publish_strict_host_key_checking)
: undefined,
remote_publish_connect_timeout: a.remote_publish_connect_timeout
? parsePositiveInt("remote_publish_connect_timeout", a.remote_publish_connect_timeout)
: undefined,
remote_publish_proxy_jump: a.remote_publish_proxy_jump || undefined,
}));
}
@@ -133,18 +207,19 @@ export function loadWechatExtendConfig(): WechatExtendConfig {
const paths = [
path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
path.join(
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config"),
"baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"
),
path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
path.join(homeDir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
];
for (const p of paths) {
let content: string;
try {
const content = fs.readFileSync(p, "utf-8");
return parseWechatExtend(content);
content = fs.readFileSync(p, "utf-8");
} catch {
continue;
}
return parseWechatExtend(content);
}
return {};
}
@@ -168,6 +243,15 @@ export function resolveAccount(config: WechatExtendConfig, alias?: string): Reso
app_id: acct?.app_id,
app_secret: acct?.app_secret,
chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path,
remote_publish_host: acct?.remote_publish_host ?? config.remote_publish_host,
remote_publish_user: acct?.remote_publish_user ?? config.remote_publish_user,
remote_publish_port: acct?.remote_publish_port ?? config.remote_publish_port,
remote_publish_identity_file: acct?.remote_publish_identity_file ?? config.remote_publish_identity_file,
remote_publish_known_hosts_file: acct?.remote_publish_known_hosts_file ?? config.remote_publish_known_hosts_file,
remote_publish_strict_host_key_checking:
acct?.remote_publish_strict_host_key_checking ?? config.remote_publish_strict_host_key_checking,
remote_publish_connect_timeout: acct?.remote_publish_connect_timeout ?? config.remote_publish_connect_timeout,
remote_publish_proxy_jump: acct?.remote_publish_proxy_jump ?? config.remote_publish_proxy_jump,
};
}
@@ -273,7 +357,7 @@ function resolveCredentialSource(
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(homeDir(), ".baoyu-skills", ".env");
const cwdEnv = loadEnvFile(cwdEnvPath);
const homeEnv = loadEnvFile(homeEnvPath);
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import http from "node:http";
import test, { type TestContext } from "node:test";
import { buildMultipart, wechatHttp } from "./wechat-http.ts";
interface ReceivedRequest {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: Buffer;
}
async function startEchoServer(t: TestContext): Promise<{ baseUrl: string; received: ReceivedRequest[] }> {
const received: ReceivedRequest[] = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
received.push({
method: req.method ?? "",
url: req.url ?? "",
headers: req.headers,
body: Buffer.concat(chunks),
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true, echo: { url: req.url, method: req.method } }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to start echo server");
}
const baseUrl = `http://127.0.0.1:${address.port}`;
t.after(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
return { baseUrl, received };
}
test("wechatHttp performs a GET and passes through query string", async (t) => {
const { baseUrl, received } = await startEchoServer(t);
const res = await wechatHttp(`${baseUrl}/cgi-bin/token?grant_type=client_credential&appid=AID`);
assert.equal(res.status, 200);
const data = await res.json<{ ok: boolean; echo: { url: string; method: string } }>();
assert.equal(data.ok, true);
assert.equal(received.length, 1);
assert.equal(received[0]!.method, "GET");
assert.equal(received[0]!.url, "/cgi-bin/token?grant_type=client_credential&appid=AID");
assert.equal(received[0]!.body.length, 0);
});
test("wechatHttp POST sends JSON body with content-length header", async (t) => {
const { baseUrl, received } = await startEchoServer(t);
const body = JSON.stringify({ articles: [{ title: "hi" }] });
const res = await wechatHttp(`${baseUrl}/cgi-bin/draft/add?access_token=T`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
assert.equal(res.status, 200);
assert.equal(received.length, 1);
assert.equal(received[0]!.method, "POST");
assert.equal(received[0]!.headers["content-type"], "application/json");
assert.equal(received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
assert.equal(received[0]!.body.toString("utf-8"), body);
});
test("wechatHttp .text() returns the raw response body", async (t) => {
const { baseUrl } = await startEchoServer(t);
const res = await wechatHttp(`${baseUrl}/hello`);
const text = await res.text();
assert.match(text, /"ok":true/);
});
test("wechatHttp .buffer() returns a Buffer of the response body", async (t) => {
const { baseUrl } = await startEchoServer(t);
const res = await wechatHttp(`${baseUrl}/`);
const buf = await res.buffer();
assert.ok(Buffer.isBuffer(buf));
assert.ok(buf.length > 0);
});
test("buildMultipart produces a parsable multipart payload", () => {
const fileData = Buffer.from("ZZZ");
const { contentType, body } = buildMultipart([
{
name: "media",
filename: "image.png",
contentType: "image/png",
data: fileData,
},
]);
const boundaryMatch = contentType.match(/^multipart\/form-data; boundary=(.+)$/);
assert.ok(boundaryMatch, `expected boundary in Content-Type, got ${contentType}`);
const boundary = boundaryMatch![1]!;
const text = body.toString("binary");
assert.ok(text.startsWith(`--${boundary}\r\n`), "body must start with opening boundary");
assert.ok(
text.endsWith(`--${boundary}--\r\n`),
"body must end with closing boundary",
);
assert.match(
text,
/Content-Disposition: form-data; name="media"; filename="image\.png"\r\n/,
);
assert.match(text, /Content-Type: image\/png\r\n/);
// The raw file bytes must appear verbatim after a blank line.
assert.ok(
text.includes("\r\n\r\nZZZ\r\n"),
"body must contain the raw file bytes after the part headers",
);
});
test("wechatHttp accepts a multipart body produced by buildMultipart", async (t) => {
const { baseUrl, received } = await startEchoServer(t);
const fileData = Buffer.from("HELLO");
const multipart = buildMultipart([
{ name: "media", filename: "x.png", contentType: "image/png", data: fileData },
]);
const res = await wechatHttp(`${baseUrl}/cgi-bin/media/uploadimg?access_token=T`, {
method: "POST",
headers: { "Content-Type": multipart.contentType },
body: multipart.body,
});
assert.equal(res.status, 200);
assert.equal(received.length, 1);
assert.equal(received[0]!.method, "POST");
assert.match(received[0]!.headers["content-type"]!, /^multipart\/form-data; boundary=/);
assert.ok(received[0]!.body.includes(Buffer.from("HELLO")));
});
@@ -0,0 +1,97 @@
export interface WechatHttpInit {
method?: string;
headers?: Record<string, string>;
body?: string | Buffer;
}
export interface WechatHttpResponse {
status: number;
statusText: string;
headers: Record<string, string | string[] | undefined>;
buffer(): Promise<Buffer>;
text(): Promise<string>;
json<T = unknown>(): Promise<T>;
}
export type WechatClient = (
url: string,
init?: WechatHttpInit,
) => Promise<WechatHttpResponse>;
export interface MultipartFilePart {
name: string;
filename: string;
contentType: string;
data: Buffer;
}
export interface MultipartBody {
contentType: string;
body: Buffer;
}
export function buildMultipart(parts: MultipartFilePart[]): MultipartBody {
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
const chunks: Buffer[] = [];
for (const part of parts) {
const header =
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` +
`Content-Type: ${part.contentType}\r\n\r\n`;
chunks.push(Buffer.from(header, "utf-8"));
chunks.push(part.data);
chunks.push(Buffer.from("\r\n", "utf-8"));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf-8"));
return {
contentType: `multipart/form-data; boundary=${boundary}`,
body: Buffer.concat(chunks),
};
}
function headersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
const out: Record<string, string | string[] | undefined> = {};
headers.forEach((value, key) => {
const existing = out[key];
if (existing === undefined) {
out[key] = value;
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
out[key] = [existing, value];
}
});
return out;
}
export const wechatHttp: WechatClient = async (url, init = {}) => {
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
const headers: Record<string, string> = { ...(init.headers ?? {}) };
let body: BodyInit | undefined;
if (init.body !== undefined) {
body = Buffer.isBuffer(init.body)
? new Uint8Array(init.body.buffer, init.body.byteOffset, init.body.byteLength)
: init.body;
}
const res = await fetch(url, { method, headers, body });
const buf = Buffer.from(await res.arrayBuffer());
return {
status: res.status,
statusText: res.statusText,
headers: headersToRecord(res.headers),
async buffer() {
return buf;
},
async text() {
return buf.toString("utf-8");
},
async json<T = unknown>() {
return JSON.parse(buf.toString("utf-8")) as T;
},
};
};
@@ -0,0 +1,82 @@
import fs from "node:fs";
import path from "node:path";
import {
type WechatUploadAsset,
detectImageFormatFromBuffer,
} from "./wechat-image-processor.ts";
export type { WechatUploadAsset };
const MIME_TYPES_BY_EXT: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff",
".tif": "image/tiff",
".svg": "image/svg+xml",
".ico": "image/x-icon",
};
export async function loadUploadAsset(
imagePath: string,
baseDir?: string,
): Promise<WechatUploadAsset> {
let fileBuffer: Buffer;
let filename: string;
let contentType: string;
let fileSize = 0;
let fileExt = "";
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to download image: ${imagePath}`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error(`Remote image is empty: ${imagePath}`);
}
fileBuffer = Buffer.from(buffer);
fileSize = buffer.byteLength;
const urlPath = imagePath.split("?")[0]!;
filename = path.basename(urlPath) || "image.jpg";
fileExt = path.extname(filename).toLowerCase();
contentType = response.headers.get("content-type") || "image/jpeg";
} else {
const resolvedPath = path.isAbsolute(imagePath)
? imagePath
: path.resolve(baseDir || process.cwd(), imagePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Image not found: ${resolvedPath}`);
}
const stats = fs.statSync(resolvedPath);
if (stats.size === 0) {
throw new Error(`Local image is empty: ${resolvedPath}`);
}
fileSize = stats.size;
fileBuffer = fs.readFileSync(resolvedPath);
filename = path.basename(resolvedPath);
fileExt = path.extname(filename).toLowerCase();
contentType = MIME_TYPES_BY_EXT[fileExt] || "image/jpeg";
}
const detected = detectImageFormatFromBuffer(fileBuffer);
if (detected && detected.contentType !== contentType) {
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
contentType = detected.contentType;
fileExt = detected.fileExt;
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
}
return {
buffer: fileBuffer,
filename,
contentType,
fileExt,
fileSize,
};
}
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import net from "node:net";
import test from "node:test";
import {
buildSshArgs,
findFreePort,
normalizeRemoteConfig,
} from "./wechat-remote-publish.ts";
test("normalizeRemoteConfig requires a host", () => {
assert.throws(
() => normalizeRemoteConfig({ host: "" }),
/Remote publish host is required/,
);
assert.throws(
() => normalizeRemoteConfig({ host: " " }),
/Remote publish host is required/,
);
});
test("normalizeRemoteConfig applies user/port defaults and trims host", () => {
const result = normalizeRemoteConfig({ host: " example.com " });
assert.equal(result.host, "example.com");
assert.equal(result.user, "root");
assert.equal(result.port, 22);
assert.equal(result.identityFile, undefined);
assert.equal(result.knownHostsFile, undefined);
assert.equal(result.strictHostKeyChecking, undefined);
assert.equal(result.connectTimeout, undefined);
assert.equal(result.proxyJump, undefined);
});
test("normalizeRemoteConfig preserves explicit user, port, and SSH options", () => {
const result = normalizeRemoteConfig({
host: "example.com",
user: "deploy",
port: 2222,
identityFile: "/home/me/.ssh/id_ed25519",
knownHostsFile: "/home/me/.ssh/known_hosts",
strictHostKeyChecking: "accept-new",
connectTimeout: 15,
proxyJump: "bastion.example.com",
});
assert.equal(result.user, "deploy");
assert.equal(result.port, 2222);
assert.equal(result.identityFile, "/home/me/.ssh/id_ed25519");
assert.equal(result.knownHostsFile, "/home/me/.ssh/known_hosts");
assert.equal(result.strictHostKeyChecking, "accept-new");
assert.equal(result.connectTimeout, 15);
assert.equal(result.proxyJump, "bastion.example.com");
});
test("normalizeRemoteConfig rejects invalid port", () => {
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", port: 0 }),
/Invalid remote publish port/,
);
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", port: 65536 }),
/Invalid remote publish port/,
);
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", port: 1.5 }),
/Invalid remote publish port/,
);
});
test("normalizeRemoteConfig rejects invalid connect timeout", () => {
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: 0 }),
/Invalid remote_publish_connect_timeout/,
);
assert.throws(
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: -3 }),
/Invalid remote_publish_connect_timeout/,
);
});
test("normalizeRemoteConfig rejects invalid strict host key checking", () => {
assert.throws(
() =>
normalizeRemoteConfig({
host: "example.com",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
strictHostKeyChecking: "maybe" as any,
}),
/Invalid remote_publish_strict_host_key_checking/,
);
});
test("normalizeRemoteConfig falls back to default user when blank string provided", () => {
const result = normalizeRemoteConfig({ host: "example.com", user: " " });
assert.equal(result.user, "root");
});
test("buildSshArgs emits the whitelisted minimum set", () => {
const args = buildSshArgs(
{ host: "example.com", user: "root", port: 22 },
1080,
);
assert.deepEqual(args, [
"-N",
"-T",
"-D", "127.0.0.1:1080",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-p", "22",
"root@example.com",
]);
});
test("buildSshArgs threads optional ssh options in stable order", () => {
const args = buildSshArgs(
{
host: "example.com",
user: "deploy",
port: 2222,
identityFile: "/p/id_ed25519",
knownHostsFile: "/p/known_hosts",
strictHostKeyChecking: "accept-new",
connectTimeout: 12,
proxyJump: "bastion.example.com",
},
1080,
);
assert.deepEqual(args, [
"-N",
"-T",
"-D", "127.0.0.1:1080",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-p", "2222",
"-i", "/p/id_ed25519",
"-o", "UserKnownHostsFile=/p/known_hosts",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=12",
"-J", "bastion.example.com",
"deploy@example.com",
]);
});
test("buildSshArgs does not emit raw ssh options for unknown fields", () => {
const args = buildSshArgs(
{
host: "example.com",
user: "root",
port: 22,
// No extra unknown keys are accepted — typed config is the whitelist.
},
1080,
);
assert.equal(
args.filter((a) => a === "-o" || a.startsWith("--")).length,
3, // ExitOnForwardFailure, ServerAliveInterval, ServerAliveCountMax (and only those)
"buildSshArgs must only emit the three baseline -o options when no extras given",
);
});
test("buildSshArgs rejects invalid SOCKS port", () => {
assert.throws(
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 0),
/Invalid SOCKS port/,
);
assert.throws(
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 70_000),
/Invalid SOCKS port/,
);
});
test("findFreePort returns a usable loopback port", async () => {
const port = await findFreePort();
assert.ok(port > 0 && port < 65536, `expected valid port, got ${port}`);
await new Promise<void>((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.close((err) => (err ? reject(err) : resolve()));
});
});
});
@@ -0,0 +1,274 @@
import { spawn, type ChildProcessByStdio } from "node:child_process";
import net from "node:net";
import type { Readable } from "node:stream";
import type { StrictHostKeyChecking } from "./wechat-extend-config.ts";
import type { WechatClient } from "./wechat-http.ts";
import { createSocksClient } from "./wechat-socks-http.ts";
export interface RemotePublishConfig {
host: string;
user?: string;
port?: number;
identityFile?: string;
knownHostsFile?: string;
strictHostKeyChecking?: StrictHostKeyChecking;
connectTimeout?: number;
proxyJump?: string;
}
export interface NormalizedRemotePublishConfig {
host: string;
user: string;
port: number;
identityFile?: string;
knownHostsFile?: string;
strictHostKeyChecking?: StrictHostKeyChecking;
connectTimeout?: number;
proxyJump?: string;
}
export interface SshTunnel {
port: number;
client: WechatClient;
close: () => Promise<void>;
}
export interface StartSshTunnelOptions {
readyTimeoutMs?: number;
killTimeoutMs?: number;
}
const DEFAULT_USER = "root";
const DEFAULT_PORT = 22;
const DEFAULT_READY_TIMEOUT_MS = 10_000;
const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const SSH_LOOPBACK_HOST = "127.0.0.1";
export function normalizeRemoteConfig(config: RemotePublishConfig): NormalizedRemotePublishConfig {
if (!config.host || !config.host.trim()) {
throw new Error("Remote publish host is required (set remote_publish_host or --remote-host).");
}
const port = config.port ?? DEFAULT_PORT;
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid remote publish port: ${config.port}`);
}
if (config.connectTimeout !== undefined) {
if (!Number.isInteger(config.connectTimeout) || config.connectTimeout <= 0) {
throw new Error(`Invalid remote_publish_connect_timeout: ${config.connectTimeout}`);
}
}
if (
config.strictHostKeyChecking !== undefined &&
config.strictHostKeyChecking !== "yes" &&
config.strictHostKeyChecking !== "no" &&
config.strictHostKeyChecking !== "accept-new"
) {
throw new Error(`Invalid remote_publish_strict_host_key_checking: ${config.strictHostKeyChecking}`);
}
return {
host: config.host.trim(),
user: (config.user ?? DEFAULT_USER).trim() || DEFAULT_USER,
port,
identityFile: config.identityFile,
knownHostsFile: config.knownHostsFile,
strictHostKeyChecking: config.strictHostKeyChecking,
connectTimeout: config.connectTimeout,
proxyJump: config.proxyJump,
};
}
export function buildSshArgs(config: NormalizedRemotePublishConfig, socksPort: number): string[] {
if (!Number.isInteger(socksPort) || socksPort < 1 || socksPort > 65535) {
throw new Error(`Invalid SOCKS port: ${socksPort}`);
}
const args: string[] = [
"-N",
"-T",
"-D", `${SSH_LOOPBACK_HOST}:${socksPort}`,
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-p", String(config.port),
];
if (config.identityFile) {
args.push("-i", config.identityFile);
}
if (config.knownHostsFile) {
args.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`);
}
if (config.strictHostKeyChecking) {
args.push("-o", `StrictHostKeyChecking=${config.strictHostKeyChecking}`);
}
if (config.connectTimeout !== undefined) {
args.push("-o", `ConnectTimeout=${config.connectTimeout}`);
}
if (config.proxyJump) {
args.push("-J", config.proxyJump);
}
args.push(`${config.user}@${config.host}`);
return args;
}
export async function findFreePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, SSH_LOOPBACK_HOST, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire free port")));
return;
}
const port = address.port;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
export async function waitForSocksReady(port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown = undefined;
while (Date.now() < deadline) {
try {
await tryConnect(port);
return;
} catch (err) {
lastError = err;
await sleep(150);
}
}
throw new Error(`SOCKS proxy on ${SSH_LOOPBACK_HOST}:${port} not ready within ${timeoutMs}ms${lastError ? `: ${(lastError as Error).message}` : ""}`);
}
function tryConnect(port: number): Promise<void> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host: SSH_LOOPBACK_HOST, port });
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("error", (err) => {
socket.destroy();
reject(err);
});
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function startSshTunnel(
config: NormalizedRemotePublishConfig,
options: StartSshTunnelOptions = {},
): Promise<SshTunnel> {
const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
const killTimeout = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const port = await findFreePort();
const args = buildSshArgs(config, port);
console.error(`[wechat-remote-publish] Starting SSH SOCKS5 tunnel: ssh ${args.join(" ")}`);
const child = spawn("ssh", args, {
stdio: ["ignore", "pipe", "pipe"],
}) as ChildProcessByStdio<null, Readable, Readable>;
const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk.toString("utf-8"));
});
let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
child.once("exit", (code, signal) => {
earlyExit = { code, signal };
});
try {
await waitForSocksReady(port, readyTimeout);
} catch (err) {
await killChild(child, killTimeout);
const stderrTail = stderrChunks.join("").trim().split("\n").slice(-5).join("\n");
const suffix = stderrTail ? `\nssh stderr (tail):\n${stderrTail}` : "";
const exitSuffix = earlyExit
? `\nssh exited early with code=${earlyExit.code} signal=${earlyExit.signal}`
: "";
throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`);
}
const client = createSocksClient({ host: SSH_LOOPBACK_HOST, port });
const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];
let closed = false;
const close = async (): Promise<void> => {
if (closed) return;
closed = true;
for (const { signal, handler } of signalHandlers) {
process.off(signal, handler);
}
await killChild(child, killTimeout);
};
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
const handler = () => {
void close();
};
process.once(signal, handler);
signalHandlers.push({ signal, handler });
}
return { port, client, close };
}
async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, killTimeoutMs: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const exited = new Promise<void>((resolve) => {
child.once("exit", () => resolve());
});
try {
child.kill("SIGTERM");
} catch {
/* already dead */
}
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* already dead */
}
}, killTimeoutMs);
try {
await exited;
} finally {
clearTimeout(timer);
}
}
export async function withSshTunnel<T>(
config: NormalizedRemotePublishConfig,
fn: (client: WechatClient) => Promise<T>,
options?: StartSshTunnelOptions,
): Promise<T> {
const tunnel = await startSshTunnel(config, options);
try {
return await fn(tunnel.client);
} finally {
await tunnel.close();
}
}
@@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import http from "node:http";
import net from "node:net";
import test, { type TestContext } from "node:test";
import { createSocksClient } from "./wechat-socks-http.ts";
interface EchoServer {
baseUrl: string;
port: number;
received: Array<{ method: string; url: string; headers: http.IncomingHttpHeaders; body: Buffer }>;
}
async function startEchoServer(t: TestContext): Promise<EchoServer> {
const received: EchoServer["received"] = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
received.push({
method: req.method ?? "",
url: req.url ?? "",
headers: req.headers,
body: Buffer.concat(chunks),
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true, url: req.url }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("echo server bind failed");
const port = address.port;
t.after(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
return { baseUrl: `http://127.0.0.1:${port}`, port, received };
}
interface FakeSocks5 {
port: number;
connectionCount: () => number;
destinations: () => Array<{ host: string; port: number }>;
}
async function startFakeSocks5(t: TestContext): Promise<FakeSocks5> {
let connectionCount = 0;
const destinations: Array<{ host: string; port: number }> = [];
const server = net.createServer((client) => {
connectionCount++;
let phase: "greeting" | "request" | "tunnel" = "greeting";
let buf = Buffer.alloc(0);
let upstream: net.Socket | undefined;
const tryParse = () => {
if (phase === "greeting") {
if (buf.length < 2) return;
const nMethods = buf[1]!;
if (buf.length < 2 + nMethods) return;
buf = buf.subarray(2 + nMethods);
client.write(Buffer.from([0x05, 0x00]));
phase = "request";
}
if (phase === "request") {
if (buf.length < 5) return;
if (buf[0] !== 0x05 || buf[1] !== 0x01) {
client.destroy();
return;
}
const atyp = buf[3];
let addrEnd: number;
let host: string;
if (atyp === 0x01) {
if (buf.length < 4 + 4 + 2) return;
host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
addrEnd = 4 + 4;
} else if (atyp === 0x03) {
const dlen = buf[4]!;
if (buf.length < 4 + 1 + dlen + 2) return;
host = buf.subarray(5, 5 + dlen).toString("ascii");
addrEnd = 4 + 1 + dlen;
} else {
client.destroy();
return;
}
const port = (buf[addrEnd]! << 8) | buf[addrEnd + 1]!;
destinations.push({ host, port });
const totalLen = addrEnd + 2;
const remaining = buf.subarray(totalLen);
buf = Buffer.alloc(0);
upstream = net.connect({ host, port }, () => {
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
phase = "tunnel";
if (remaining.length > 0) {
upstream!.write(remaining);
}
});
upstream.on("data", (data: Buffer) => {
client.write(data);
});
upstream.on("end", () => {
try { client.end(); } catch { /* noop */ }
});
upstream.on("error", () => {
try { client.destroy(); } catch { /* noop */ }
});
}
};
client.on("data", (chunk: Buffer) => {
if (phase === "tunnel") {
upstream?.write(chunk);
return;
}
buf = Buffer.concat([buf, chunk]);
tryParse();
});
client.on("end", () => {
try { upstream?.end(); } catch { /* noop */ }
});
client.on("error", () => {
try { upstream?.destroy(); } catch { /* noop */ }
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("socks server bind failed");
const port = address.port;
t.after(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
return {
port,
connectionCount: () => connectionCount,
destinations: () => destinations,
};
}
test("createSocksClient routes plain HTTP through the SOCKS5 proxy", async (t) => {
const echo = await startEchoServer(t);
const socks = await startFakeSocks5(t);
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
const res = await client(`${echo.baseUrl}/cgi-bin/token?appid=AID`);
assert.equal(res.status, 200);
const data = await res.json<{ ok: boolean; url: string }>();
assert.equal(data.ok, true);
assert.equal(data.url, "/cgi-bin/token?appid=AID");
assert.equal(
socks.connectionCount(),
1,
"SOCKS5 proxy must have received exactly one connection (proves bytes were routed through it)",
);
const dests = socks.destinations();
assert.equal(dests.length, 1);
assert.equal(dests[0]!.host, "127.0.0.1");
assert.equal(dests[0]!.port, echo.port);
assert.equal(echo.received.length, 1);
assert.equal(echo.received[0]!.method, "GET");
assert.equal(echo.received[0]!.url, "/cgi-bin/token?appid=AID");
});
test("createSocksClient sends POST body through the SOCKS5 proxy", async (t) => {
const echo = await startEchoServer(t);
const socks = await startFakeSocks5(t);
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
const body = JSON.stringify({ articles: [{ title: "hi" }] });
const res = await client(`${echo.baseUrl}/cgi-bin/draft/add?access_token=T`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
assert.equal(res.status, 200);
assert.equal(socks.connectionCount(), 1);
assert.equal(echo.received.length, 1);
assert.equal(echo.received[0]!.method, "POST");
assert.equal(echo.received[0]!.headers["content-type"], "application/json");
assert.equal(echo.received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
assert.equal(echo.received[0]!.body.toString("utf-8"), body);
});
test("createSocksClient rejects invalid proxy ports", () => {
assert.throws(
() => createSocksClient({ host: "127.0.0.1", port: 0 }),
/Invalid SOCKS proxy port/,
);
assert.throws(
() => createSocksClient({ host: "127.0.0.1", port: 70_000 }),
/Invalid SOCKS proxy port/,
);
assert.throws(
() => createSocksClient({ host: "", port: 1080 }),
/SOCKS proxy host required/,
);
});
@@ -0,0 +1,229 @@
import net from "node:net";
import tls from "node:tls";
import { URL } from "node:url";
import { SocksClient } from "socks";
import type {
WechatClient,
WechatHttpInit,
WechatHttpResponse,
} from "./wechat-http.ts";
export interface SocksProxyEndpoint {
host: string;
port: number;
}
export function createSocksClient(proxy: SocksProxyEndpoint): WechatClient {
if (!proxy.host) throw new Error("SOCKS proxy host required");
if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) {
throw new Error(`Invalid SOCKS proxy port: ${proxy.port}`);
}
return async (url, init = {}) => {
return wechatHttpViaSocks(url, init, proxy);
};
}
async function wechatHttpViaSocks(
rawUrl: string,
init: WechatHttpInit,
proxy: SocksProxyEndpoint,
): Promise<WechatHttpResponse> {
const url = new URL(rawUrl);
const isHttps = url.protocol === "https:";
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error(`Unsupported protocol for SOCKS client: ${url.protocol}`);
}
const targetPort = url.port ? Number(url.port) : isHttps ? 443 : 80;
const { socket: tcpSocket } = await SocksClient.createConnection({
proxy: { host: proxy.host, port: proxy.port, type: 5 },
command: "connect",
destination: { host: url.hostname, port: targetPort },
});
let stream: net.Socket | tls.TLSSocket;
if (isHttps) {
const tlsSocket = tls.connect({ socket: tcpSocket, servername: url.hostname });
await new Promise<void>((resolve, reject) => {
const onSecure = () => {
tlsSocket.removeListener("error", onError);
resolve();
};
const onError = (err: Error) => {
tlsSocket.removeListener("secureConnect", onSecure);
try {
tlsSocket.destroy();
} catch {
/* noop */
}
try {
tcpSocket.destroy();
} catch {
/* noop */
}
reject(err);
};
tlsSocket.once("secureConnect", onSecure);
tlsSocket.once("error", onError);
});
stream = tlsSocket;
} else {
stream = tcpSocket;
}
try {
return await sendRequestAndReadResponse(stream, url, init);
} finally {
try {
stream.destroy();
} catch {
/* noop */
}
}
}
async function sendRequestAndReadResponse(
stream: net.Socket | tls.TLSSocket,
url: URL,
init: WechatHttpInit,
): Promise<WechatHttpResponse> {
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
const body =
init.body === undefined
? undefined
: Buffer.isBuffer(init.body)
? init.body
: Buffer.from(init.body, "utf-8");
const userHeaders = init.headers ?? {};
const headerMap = new Map<string, string>();
for (const [k, v] of Object.entries(userHeaders)) {
headerMap.set(k.toLowerCase(), `${k}: ${v}`);
}
if (!headerMap.has("host")) headerMap.set("host", `Host: ${url.host}`);
if (!headerMap.has("user-agent")) {
headerMap.set("user-agent", "User-Agent: baoyu-skills-wechat-api");
}
headerMap.set("connection", "Connection: close");
if (body && !headerMap.has("content-length")) {
headerMap.set("content-length", `Content-Length: ${body.length}`);
}
const path = `${url.pathname || "/"}${url.search}`;
const requestHeader = Buffer.from(
`${method} ${path} HTTP/1.1\r\n` +
Array.from(headerMap.values()).join("\r\n") +
"\r\n\r\n",
"utf-8",
);
await writeAll(stream, requestHeader);
if (body) await writeAll(stream, body);
const raw = await readToEnd(stream);
return parseHttpResponse(raw);
}
function writeAll(stream: net.Socket | tls.TLSSocket, data: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
stream.write(data, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function readToEnd(stream: net.Socket | tls.TLSSocket): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.once("end", () => resolve(Buffer.concat(chunks)));
stream.once("error", reject);
});
}
function parseHttpResponse(raw: Buffer): WechatHttpResponse {
const headerEnd = raw.indexOf("\r\n\r\n");
if (headerEnd < 0) {
throw new Error("Malformed HTTP response: missing header terminator");
}
const headerText = raw.subarray(0, headerEnd).toString("utf-8");
let bodyBytes = raw.subarray(headerEnd + 4);
const lines = headerText.split("\r\n");
const statusLine = lines.shift() ?? "";
const statusMatch = statusLine.match(/^HTTP\/[\d.]+\s+(\d+)(?:\s+(.*))?$/);
if (!statusMatch) {
throw new Error(`Malformed HTTP status line: ${statusLine}`);
}
const status = Number.parseInt(statusMatch[1]!, 10);
const statusText = statusMatch[2] ?? "";
const headers: Record<string, string | string[] | undefined> = {};
const lowercaseHeaders: Record<string, string> = {};
for (const line of lines) {
const colon = line.indexOf(":");
if (colon < 0) continue;
const key = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
const lower = key.toLowerCase();
const existing = headers[lower];
if (existing === undefined) {
headers[lower] = value;
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
headers[lower] = [existing, value];
}
lowercaseHeaders[lower] = value;
}
const transferEncoding = (lowercaseHeaders["transfer-encoding"] ?? "").toLowerCase();
if (transferEncoding.split(",").map((s) => s.trim()).includes("chunked")) {
bodyBytes = dechunk(bodyBytes);
} else if (lowercaseHeaders["content-length"] !== undefined) {
const length = Number.parseInt(lowercaseHeaders["content-length"]!, 10);
if (Number.isFinite(length) && length >= 0) {
bodyBytes = bodyBytes.subarray(0, length);
}
}
return {
status,
statusText,
headers,
async buffer() {
return bodyBytes;
},
async text() {
return bodyBytes.toString("utf-8");
},
async json<T = unknown>() {
return JSON.parse(bodyBytes.toString("utf-8")) as T;
},
};
}
function dechunk(raw: Buffer): Buffer {
const parts: Buffer[] = [];
let offset = 0;
while (offset < raw.length) {
const lineEnd = raw.indexOf("\r\n", offset);
if (lineEnd < 0) break;
const sizeText = raw.subarray(offset, lineEnd).toString("ascii").split(";")[0]!.trim();
const size = Number.parseInt(sizeText, 16);
if (!Number.isFinite(size) || size < 0) {
throw new Error(`Invalid chunked-encoding size: ${sizeText}`);
}
offset = lineEnd + 2;
if (size === 0) break;
if (offset + size > raw.length) {
throw new Error("Chunked-encoding body truncated");
}
parts.push(raw.subarray(offset, offset + size));
offset += size + 2;
}
return Buffer.concat(parts);
}
+8
View File
@@ -40,6 +40,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace slide titles, bullets, or any other text inside an already generated slide image. If text is wrong or unclear, regenerate from a corrected prompt, simplify the slide's on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-slide-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -337,6 +339,12 @@ PDF: {topic-slug}.pdf
Always update the prompt file before regenerating the image — this keeps the prompts directory as the source of truth and makes changes reproducible. Only `NN` changes on renumber; slugs stay stable so references remain valid.
Text correction policy:
- If a slide's title, bullets, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
See `references/modification-guide.md` for full details.
## References
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-wechat-summary
description: Summarizes WeChat group chat highlights into a structured digest using the local wx-cli binary (https://github.com/jackwener/wx-cli). Generates a normal digest by default; a roast (毒舌) version is opt-in. Maintains per-group history (history.json + history-digests.jsonl) and per-user profiles across runs, with privacy guardrails baked in. Use when the user asks to "总结群聊", "群聊精华", "群聊摘要", "summarize group chat", "group chat digest", mentions a WeChat group name with a time range, says "帮我看看 XX 群最近聊了什么", "XX 群有什么值得看的", or asks to "回溯画像" / "初始化画像" / "backfill profiles". Adds the roast version when the user says "毒舌版", "roast 版", "再来个毒舌的", or similar.
version: 0.1.0
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-wechat-summary
@@ -32,7 +32,9 @@ YAML frontmatter at the top of every profile file:
---
name: "<current display name>"
wxid: "<wxid>"
aliases: ["<old nickname>", "<even older nickname>"]
group_nicknames: ["<历史群昵称 1>", "<历史群昵称 2>"]
aliases: ["<群友给的称呼 1>", "<群友给的称呼 2>"]
tags: ["<标签 1>", "<标签 2>"]
first_seen: "YYYY-MM-DD"
last_seen: "YYYY-MM-DD"
total_messages: N
@@ -45,12 +47,16 @@ Field rules:
- `name`: the most recent display name from `from_nickname` (or `self_display` for the owning user).
- `wxid`: stable; never changes once written.
- `aliases`: append-only; every prior display name we've seen. Don't include the current `name` in this list.
- `group_nicknames`: append-only history of the user's own prior display names in the group. Push the prior `name` here when `name` changes. Dedupe, preserve chronological order (oldest → newest). Do not include the current `name`.
- `aliases`: nicknames **other members** call this user (e.g., `蛙总`, `老王`, `X 哥`). Dedupe-append when observed in this batch. Do not include the current `name`, and do not duplicate `group_nicknames` entries — those record the user's own past handles, not how the group addresses them.
- `tags`: free-form labels for the user, **independent** of the body's 角色标签 / 人设标签 section. Use for cross-cutting attributes that don't fit the role/personality framing (region, profession, community, recurring long-form interests, etc.). Agent may append or refine when observing stable patterns. No hard cap.
- `first_seen` / `last_seen`: dates of first/most-recent digest appearance, YYYY-MM-DD.
- `total_messages`: cumulative count across all digests this profile has been updated from.
- `digest_appearances`: how many digest files this user has 3+ messages in.
- `avg_messages_per_digest`: `total_messages / digest_appearances`, one decimal.
**Backwards compatibility**: earlier versions of this skill used `aliases` for what is now `group_nicknames`. When reading an existing profile that lacks `group_nicknames` or `tags`, treat missing fields as `[]` and add them on the next write. **Do not auto-migrate** non-empty legacy `aliases` values — the agent can't reliably tell historical display names apart from community-given nicknames. Leave the values in `aliases`; the user can move historical display names into `group_nicknames` manually if desired.
### 1.3 Free-form body — normal profile
Section headers are plain text on their own line. Order is fixed.
@@ -138,8 +144,16 @@ Rules differ per section. Append-only sections must never lose history; mergeabl
### 2.3 Frontmatter on every update
- Update `name` if current display name differs from the recorded one. Push the old name onto `aliases` if not already there.
- If `name` changed, also rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
- If the current display name differs from the recorded `name`:
- Push the old `name` onto `group_nicknames` if not already there (dedupe, preserve chronological order).
- Update `name` to the current display name.
- Rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
- Scan this batch for nicknames **other members** use to address this user, and dedupe-append into `aliases`. Signals:
- `@mention` resolving to this `wxid`.
- Direct salutations targeting this user with a name different from `name` (e.g., `蛙总你怎么看`, `老王说得对`).
- Quoted references in the digest body that name this user as someone other than their current `name`.
- Only add when attribution is unambiguous; skip uncertain matches.
- If this batch reveals a stable cross-cutting attribute that doesn't fit the role/personality framing of 角色标签 / 人设标签 (region, profession, community, durable interest, etc.), append or refine `tags`. `tags` is independent of the body's tag sections — don't mirror them.
- Update `last_seen` to the current digest's end date.
- Increment `total_messages` by this batch's message count for this user.
- Increment `digest_appearances` by 1.
@@ -154,7 +168,7 @@ Run after the digest file(s) are written. Iterate over every user with 3+ messag
1. **Look up the profile.**
- Scan `profiles/` (or `profiles-roast/` for the roast pass) for a file whose name starts with `{wxid}-`.
- If found: open it.
- If not found: create a new file using the frontmatter template. `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`.
- If not found: create a new file using the frontmatter template. `group_nicknames = []`, `aliases = []`, `tags = []`, `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`. Then run §2.3 to seed observed aliases/tags from this batch.
2. **Resolve wxid for new users.** When a new user appears, you already know their `wxid` from the wx-cli message data — use it directly. If for some reason only the nickname is known, run `wx contacts --query "{nickname}" --json` to resolve; if multiple matches, prefer the one currently in the group (cross-check `wx members <group>` if needed).
@@ -195,7 +209,7 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
5. **Write profile files.**
- For the normal pass, write to `profiles/{wxid}-{nickname}.md`.
- For the roast pass, write to `profiles-roast/{wxid}-{nickname}.md`.
- Use the most recent nickname as the filename suffix. Push older nicknames into `aliases`.
- Use the most recent nickname as the filename suffix. Push older display names into `group_nicknames` (see step 6 for the field-by-field rules).
- Sort 经典金句,标志性事件,毒舌语录库,经典翻车现场 entries chronologically by date.
- No cap on the size of append-only sections during backfill — let history flow in.
@@ -204,6 +218,9 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
- `last_seen` = latest digest date the user appeared in.
- `total_messages` = sum of per-digest counts.
- `digest_appearances` = number of digests the user crossed the 3-message threshold in.
- `group_nicknames` = best-effort. If the same `wxid` appears under multiple distinct display names across historical digests (e.g., via the leaderboard line "X — N 条" where X varied), fill the older ones in chronological order (newest stays in `name`). If chronological order is unclear, dedupe and let later runs correct.
- `aliases` = best-effort. Scan historical digest bodies for forms where another member calls this user by a name different from their current `name` (@mentions, direct salutations). Skip uncertain matches; leave `[]` if nothing reliable surfaces.
- `tags` = `[]`. Backfill does not seed `tags`; let normal runs accumulate them.
7. **Report.** After both passes complete, print a short summary:
- `Backfilled {N} normal profiles from {M} digests.`
@@ -264,7 +281,8 @@ When loading profile context for a fresh digest:
2. For the normal pass, read `profiles/{wxid}-*.md` for each. Skip if missing.
3. If the current run also generates the roast version, **separately** read `profiles-roast/{wxid}-*.md` during the roast generation pass.
4. Compile a condensed working-memory block:
- The user's current `name` and `aliases` (so you can recognize them under different names).
- The user's current `name`, `group_nicknames`, and `aliases` (so you can recognize them under prior display names or community-given nicknames).
- `tags` (cross-cutting attributes — region, profession, community — useful for callouts in 群友画像).
- 角色标签 / 人设标签 (so you can carry forward or contrast).
- The 3-5 most recent 经典金句 / 毒舌语录 entries (so you can detect callbacks and repeats).
- The 3-5 most recent 标志性事件 / 翻车现场 entries (so you can spot recurring themes).
+8
View File
@@ -37,6 +37,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -432,6 +434,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
Text correction policy:
- If a card's title, body copy, tags, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## References
| File | Content |