mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1406a85331 | |||
| dac5867f8a | |||
| 49829e7f98 | |||
| 156f8627c2 | |||
| e0b861c148 | |||
| 3b29f3c57c | |||
| f8fb457f36 | |||
| 174e472a39 | |||
| 8b99fa7af0 | |||
| 6699b802c0 | |||
| edcdc19ac5 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.117.2"
|
||||
"version": "1.117.5"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
[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
|
||||
|
||||
### 文档
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 接口只返回 URL,material/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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user