diff --git a/README.md b/README.md index 2c89f8e..23c497e 100644 --- a/README.md +++ b/README.md @@ -581,6 +581,47 @@ To obtain credentials: **Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved). +**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`: + +```bash +mkdir -p .baoyu-skills/baoyu-post-to-wechat +``` + +Create `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`: + +```yaml +# Global settings (shared across all accounts) +default_theme: default +default_color: blue + +# Account list +accounts: + - name: My Tech Blog + alias: tech-blog + default: false + default_publish_method: api + default_author: Author Name + need_open_comment: 1 + only_fans_can_comment: 0 + app_id: wx1234567890abcdef + app_secret: your_app_secret_here + - name: AI Newsletter + alias: ai-news + default_publish_method: browser + default_author: AI Newsletter + need_open_comment: 1 + only_fans_can_comment: 0 +``` + +| Accounts configured | Behavior | +|---------------------|----------| +| No `accounts` block | Single-account mode (backward compatible) | +| 1 account | Auto-select, no prompt | +| 2+ accounts | Prompt to select, or use `--account ` | +| 1 account has `default: true` | Pre-selected as default | + +Each account gets an isolated Chrome profile for independent login sessions (browser method). API credentials can be set inline in EXTEND.md or via `.env` with alias-prefixed keys (e.g., `WECHAT_TECH_BLOG_APP_ID`). + #### baoyu-post-to-weibo Post content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input. Uses real Chrome with CDP to bypass anti-automation. diff --git a/README.zh.md b/README.zh.md index f078665..5cfd075 100644 --- a/README.zh.md +++ b/README.zh.md @@ -581,6 +581,47 @@ WECHAT_APP_SECRET=你的AppSecret **浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存) +**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号: + +```bash +mkdir -p .baoyu-skills/baoyu-post-to-wechat +``` + +创建 `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`: + +```yaml +# 全局设置(所有账号共享) +default_theme: default +default_color: blue + +# 账号列表 +accounts: + - name: 宝玉的技术分享 + alias: baoyu + default: false + default_publish_method: api + default_author: 宝玉 + need_open_comment: 1 + only_fans_can_comment: 0 + app_id: wx1234567890abcdef + app_secret: 你的 AppSecret + - name: AI 工具集 + alias: ai-tools + default_publish_method: browser + default_author: AI 工具集 + need_open_comment: 1 + only_fans_can_comment: 0 +``` + +| 账号配置情况 | 行为 | +|-------------|------| +| 无 `accounts` 块 | 单账号模式(向后兼容) | +| 1 个账号 | 自动选择,无需提示 | +| 2+ 个账号 | 提示选择,或使用 `--account <别名>` | +| 某账号设置 `default: true` | 预选为默认账号 | + +每个账号拥有独立的 Chrome 配置目录,保证浏览器方式下的登录会话互不干扰。API 凭证可在 EXTEND.md 中直接配置,也可通过 `.env` 文件使用别名前缀的环境变量(如 `WECHAT_BAOYU_APP_ID`)。 + #### baoyu-post-to-weibo 发布内容到微博。支持文字、图片、视频发布和头条文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。 diff --git a/skills/baoyu-post-to-wechat/SKILL.md b/skills/baoyu-post-to-wechat/SKILL.md index 8d3aea1..3892e3a 100644 --- a/skills/baoyu-post-to-wechat/SKILL.md +++ b/skills/baoyu-post-to-wechat/SKILL.md @@ -95,9 +95,115 @@ chrome_profile_path: /path/to/chrome/profile **Value priority**: 1. CLI arguments 2. Frontmatter -3. EXTEND.md +3. EXTEND.md (account-level → global-level) 4. Skill defaults +## Multi-Account Support + +EXTEND.md supports managing multiple WeChat Official Accounts. When `accounts:` block is present, each account can have its own credentials, Chrome profile, and default settings. + +**Compatibility rules**: + +| Condition | Mode | Behavior | +|-----------|------|----------| +| No `accounts` block | Single-account | Current behavior, unchanged | +| `accounts` with 1 entry | Single-account | Auto-select, no prompt | +| `accounts` with 2+ entries | Multi-account | Prompt to select before publishing | +| `accounts` with `default: true` | Multi-account | Pre-select default, user can switch | + +**Multi-account EXTEND.md example**: + +```md +default_theme: default +default_color: blue + +accounts: + - name: 宝玉的技术分享 + alias: baoyu + default: true + default_publish_method: api + default_author: 宝玉 + need_open_comment: 1 + only_fans_can_comment: 0 + app_id: wx1234567890abcdef + app_secret: abc123secret456 + - name: AI工具集 + alias: ai-tools + default_publish_method: browser + default_author: AI工具集 + need_open_comment: 1 + only_fans_can_comment: 0 +``` + +**Per-account keys** (can be set per-account or globally as fallback): +`default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path` + +**Global-only keys** (always shared across accounts): +`default_theme`, `default_color` + +### Account Selection (Step 0.5) + +Insert between Step 0 and Step 1 in the Article Posting Workflow: + +``` +if no accounts block: + → single-account mode (current behavior) +elif accounts.length == 1: + → auto-select the only account +elif --account CLI arg: + → select matching account +elif one account has default: true: + → pre-select, show: "Using account: (--account to switch)" +else: + → prompt user: + "Multiple WeChat accounts configured: + 1) () + 2) () + Select account [1-N]:" +``` + +### Credential Resolution (API Method) + +For a selected account with alias `{alias}`: + +1. `app_id` / `app_secret` inline in EXTEND.md account block +2. Env var `WECHAT_{ALIAS}_APP_ID` / `WECHAT_{ALIAS}_APP_SECRET` (alias uppercased, hyphens → underscores) +3. `.baoyu-skills/.env` with prefixed key `WECHAT_{ALIAS}_APP_ID` +4. `~/.baoyu-skills/.env` with prefixed key +5. Fallback to unprefixed `WECHAT_APP_ID` / `WECHAT_APP_SECRET` + +**.env multi-account example**: + +```bash +# Account: baoyu +WECHAT_BAOYU_APP_ID=wx1234567890abcdef +WECHAT_BAOYU_APP_SECRET=abc123secret456 + +# Account: ai-tools +WECHAT_AI_TOOLS_APP_ID=wxabcdef1234567890 +WECHAT_AI_TOOLS_APP_SECRET=def789secret012 +``` + +### Chrome Profile (Browser Method) + +Each account uses an isolated Chrome profile for independent login sessions: + +| Source | Path | +|--------|------| +| Account `chrome_profile_path` in EXTEND.md | Use as-is | +| Auto-generated from alias | `{shared_profile_parent}/wechat-{alias}/` | +| Single-account fallback | Shared default profile (current behavior) | + +### CLI `--account` Argument + +All publishing scripts accept `--account `: + +```bash +${BUN_X} {baseDir}/scripts/wechat-api.ts --theme default --account ai-tools +${BUN_X} {baseDir}/scripts/wechat-article.ts --markdown --theme default --account baoyu +${BUN_X} {baseDir}/scripts/wechat-browser.ts --markdown --images ./photos/ --account baoyu +``` + ## Pre-flight Check (Optional) Before first use, suggest running the environment check. User can skip if they prefer. @@ -139,6 +245,7 @@ Copy this checklist and check off items as you complete them: ``` Publishing Progress: - [ ] Step 0: Load preferences (EXTEND.md) +- [ ] Step 0.5: Resolve account (multi-account only) - [ ] Step 1: Determine input type - [ ] Step 2: Select method and configure credentials - [ ] Step 3: Resolve theme/color and validate metadata diff --git a/skills/baoyu-post-to-wechat/references/config/first-time-setup.md b/skills/baoyu-post-to-wechat/references/config/first-time-setup.md index 4723f18..5df16d9 100644 --- a/skills/baoyu-post-to-wechat/references/config/first-time-setup.md +++ b/skills/baoyu-post-to-wechat/references/config/first-time-setup.md @@ -152,6 +152,8 @@ options: ## EXTEND.md Template +### Single Account (Default) + ```md default_theme: [default/grace/simple/modern] default_color: [preset name, hex, or empty for theme default] @@ -162,6 +164,40 @@ only_fans_can_comment: [1/0] chrome_profile_path: ``` +### Multi-Account + +```md +default_theme: [default/grace/simple/modern] +default_color: [preset name, hex, or empty for theme default] + +accounts: + - name: [display name] + alias: [short key, e.g. "baoyu"] + default: true + default_publish_method: [api/browser] + 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] + - name: [second account name] + alias: [short key, e.g. "ai-tools"] + default_publish_method: [api/browser] + default_author: [author name] + need_open_comment: [1/0] + only_fans_can_comment: [1/0] +``` + +## Adding More Accounts Later + +After initial setup, users can add accounts by editing EXTEND.md: + +1. Add an `accounts:` block with list items +2. Move per-account settings (author, publish method, comments) into each account entry +3. Keep global settings (theme, color) at the top level +4. Each account needs a unique `alias` (used for CLI `--account` arg and Chrome profile naming) +5. Set `default: true` on the primary account + ## Modifying Preferences Later Users can edit EXTEND.md directly or delete it to trigger setup again. diff --git a/skills/baoyu-post-to-wechat/scripts/cdp.ts b/skills/baoyu-post-to-wechat/scripts/cdp.ts index 84432ad..a13d415 100644 --- a/skills/baoyu-post-to-wechat/scripts/cdp.ts +++ b/skills/baoyu-post-to-wechat/scripts/cdp.ts @@ -1,4 +1,5 @@ import { execSync, type ChildProcess } from 'node:child_process'; +import path from 'node:path'; import process from 'node:process'; import { @@ -80,6 +81,11 @@ export function getDefaultProfileDir(): string { }); } +export function getAccountProfileDir(alias: string): string { + const base = getDefaultProfileDir(); + return path.join(path.dirname(base), `wechat-${alias}`); +} + export interface ChromeSession { cdp: CdpConnection; sessionId: string; diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts index 532e2d5..b4b92dd 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts @@ -1,13 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import os from "node:os"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; - -interface WechatConfig { - appId: string; - appSecret: string; -} +import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts"; interface AccessTokenResponse { access_token?: string; @@ -38,53 +33,14 @@ interface ArticleOptions { thumbMediaId: string; articleType: ArticleType; imageMediaIds?: string[]; + needOpenComment?: number; + onlyFansCanComment?: number; } const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"; const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"; -function loadEnvFile(envPath: string): Record { - const env: Record = {}; - if (!fs.existsSync(envPath)) return env; - - const content = fs.readFileSync(envPath, "utf-8"); - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx > 0) { - const key = trimmed.slice(0, eqIdx).trim(); - let value = trimmed.slice(eqIdx + 1).trim(); - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - env[key] = value; - } - } - return env; -} - -function loadConfig(): WechatConfig { - const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env"); - const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env"); - - const cwdEnv = loadEnvFile(cwdEnvPath); - const homeEnv = loadEnvFile(homeEnvPath); - - const appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID; - const appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET; - - if (!appId || !appSecret) { - throw new Error( - "Missing WECHAT_APP_ID or WECHAT_APP_SECRET.\n" + - "Set via environment variables or in .baoyu-skills/.env file." - ); - } - - return { appId, appSecret }; -} async function fetchAccessToken(appId: string, appSecret: string): Promise { const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`; @@ -241,6 +197,9 @@ async function publishToDraft( let article: Record; + const noc = options.needOpenComment ?? 1; + const ofcc = options.onlyFansCanComment ?? 0; + if (options.articleType === "newspic") { if (!options.imageMediaIds || options.imageMediaIds.length === 0) { throw new Error("newspic requires at least one image"); @@ -249,8 +208,8 @@ async function publishToDraft( article_type: "newspic", title: options.title, content: options.content, - need_open_comment: 1, - only_fans_can_comment: 0, + need_open_comment: noc, + only_fans_can_comment: ofcc, image_info: { image_list: options.imageMediaIds.map(id => ({ image_media_id: id })), }, @@ -262,8 +221,8 @@ async function publishToDraft( title: options.title, content: options.content, thumb_media_id: options.thumbMediaId, - need_open_comment: 1, - only_fans_can_comment: 0, + need_open_comment: noc, + only_fans_can_comment: ofcc, }; if (options.author) article.author = options.author; if (options.digest) article.digest = options.digest; @@ -368,6 +327,7 @@ Options: --theme Theme name for markdown (default, grace, simple, modern). Default: default --color Primary color (blue, green, vermilion, etc. or hex) --cover Cover image path (local or URL) + --account 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 --help Show this help @@ -412,6 +372,7 @@ interface CliArgs { theme: string; color?: string; cover?: string; + account?: string; citeStatus: boolean; dryRun: boolean; } @@ -449,6 +410,8 @@ function parseArgs(argv: string[]): CliArgs { args.color = argv[++i]; } else if (arg === "--cover" && argv[i + 1]) { args.cover = argv[++i]; + } else if (arg === "--account" && argv[i + 1]) { + args.account = argv[++i]; } else if (arg === "--cite") { args.citeStatus = true; } else if (arg === "--no-cite") { @@ -550,6 +513,12 @@ async function main(): Promise { if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`); console.error(`[wechat-api] Type: ${args.articleType}`); + const extConfig = loadWechatExtendConfig(); + const resolved = resolveAccount(extConfig, args.account); + if (resolved.name) console.error(`[wechat-api] Account: ${resolved.name} (${resolved.alias})`); + + if (!author && resolved.default_author) author = resolved.default_author; + if (args.dryRun) { console.log(JSON.stringify({ articleType: args.articleType, @@ -558,13 +527,14 @@ async function main(): Promise { digest: digest || undefined, htmlPath, contentLength: htmlContent.length, + account: resolved.alias || undefined, }, null, 2)); return; } - const config = loadConfig(); + const creds = loadCredentials(resolved); console.error("[wechat-api] Fetching access token..."); - const accessToken = await fetchAccessToken(config.appId, config.appSecret); + const accessToken = await fetchAccessToken(creds.appId, creds.appSecret); console.error("[wechat-api] Uploading images..."); const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml( @@ -617,6 +587,8 @@ async function main(): Promise { thumbMediaId, articleType: args.articleType, imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined, + needOpenComment: resolved.need_open_comment, + onlyFansCanComment: resolved.only_fans_can_comment, }, accessToken); console.log(JSON.stringify({ diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-article.ts b/skills/baoyu-post-to-wechat/scripts/wechat-article.ts index 083a72a..88bdb68 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-article.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-article.ts @@ -3,7 +3,8 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts'; +import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts'; +import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts'; const WECHAT_URL = 'https://mp.weixin.qq.com/'; @@ -690,6 +691,7 @@ Options: --image Content image, can repeat (only with --content) --submit Save as draft --profile Chrome profile directory + --account Select account by alias (for multi-account setups) --cdp-port Connect to existing Chrome debug port instead of launching new instance Examples: @@ -725,6 +727,7 @@ async function main(): Promise { let submit = false; let profileDir: string | undefined; let cdpPort: number | undefined; + let accountAlias: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]!; @@ -741,9 +744,20 @@ async function main(): Promise { else if (arg === '--image' && args[i + 1]) images.push(args[++i]!); else if (arg === '--submit') submit = true; else if (arg === '--profile' && args[i + 1]) profileDir = args[++i]; + else if (arg === '--account' && args[i + 1]) accountAlias = args[++i]; else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10); } + const extConfig = loadWechatExtendConfig(); + const resolved = resolveAccount(extConfig, accountAlias); + if (resolved.name) console.log(`[wechat] Account: ${resolved.name} (${resolved.alias})`); + + if (!author && resolved.default_author) author = resolved.default_author; + + if (!profileDir && resolved.alias) { + profileDir = resolved.chrome_profile_path || getAccountProfileDir(resolved.alias); + } + if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); } if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); } diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts b/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts index b922008..ae2f07f 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts @@ -7,9 +7,11 @@ import { CdpConnection, findChromeExecutable, getDefaultProfileDir, + getAccountProfileDir, launchChrome, sleep, } from './cdp.ts'; +import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts'; const WECHAT_URL = 'https://mp.weixin.qq.com/'; @@ -664,6 +666,7 @@ Options: --image Add image (can be repeated) --submit Save as draft (default: preview only) --profile Chrome profile directory + --account Select account by alias (for multi-account setups) --help Show this help Examples: @@ -685,6 +688,7 @@ async function main(): Promise { let content: string | undefined; let markdownFile: string | undefined; let imagesDir: string | undefined; + let accountAlias: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]!; @@ -702,9 +706,19 @@ async function main(): Promise { submit = true; } else if (arg === '--profile' && args[i + 1]) { profileDir = args[++i]; + } else if (arg === '--account' && args[i + 1]) { + accountAlias = args[++i]; } } + const extConfig = loadWechatExtendConfig(); + const resolved = resolveAccount(extConfig, accountAlias); + if (resolved.name) console.log(`[wechat-browser] Account: ${resolved.name} (${resolved.alias})`); + + if (!profileDir && resolved.alias) { + profileDir = resolved.chrome_profile_path || getAccountProfileDir(resolved.alias); + } + if (!markdownFile && !title) { console.error('Error: --title or --markdown is required'); process.exit(1); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts new file mode 100644 index 0000000..da6c0c0 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts @@ -0,0 +1,245 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +export interface WechatAccount { + name: string; + alias: string; + default?: boolean; + default_publish_method?: string; + default_author?: string; + need_open_comment?: number; + only_fans_can_comment?: number; + app_id?: string; + app_secret?: string; + chrome_profile_path?: string; +} + +export interface WechatExtendConfig { + default_theme?: string; + default_color?: string; + default_publish_method?: string; + default_author?: string; + need_open_comment?: number; + only_fans_can_comment?: number; + chrome_profile_path?: string; + accounts?: WechatAccount[]; +} + +export interface ResolvedAccount { + name?: string; + alias?: string; + default_publish_method?: string; + default_author?: string; + need_open_comment: number; + only_fans_can_comment: number; + app_id?: string; + app_secret?: string; + chrome_profile_path?: string; +} + +function stripQuotes(s: string): string { + return s.replace(/^['"]|['"]$/g, ""); +} + +function toBool01(v: string): number { + return v === "1" || v === "true" ? 1 : 0; +} + +function parseWechatExtend(content: string): WechatExtendConfig { + const config: WechatExtendConfig = {}; + const lines = content.split("\n"); + let inAccounts = false; + let current: Record | null = null; + const rawAccounts: Record[] = []; + + for (const raw of lines) { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + if (trimmed === "accounts:") { + inAccounts = true; + continue; + } + + if (inAccounts) { + const listMatch = raw.match(/^\s+-\s+(.+)$/); + if (listMatch) { + if (current) rawAccounts.push(current); + current = {}; + const kv = listMatch[1]!; + const ci = kv.indexOf(":"); + if (ci > 0) { + current[kv.slice(0, ci).trim()] = stripQuotes(kv.slice(ci + 1).trim()); + } + continue; + } + + if (current && /^\s{2,}/.test(raw) && !trimmed.startsWith("-")) { + const ci = trimmed.indexOf(":"); + if (ci > 0) { + current[trimmed.slice(0, ci).trim()] = stripQuotes(trimmed.slice(ci + 1).trim()); + } + continue; + } + + if (!/^\s/.test(raw)) { + if (current) rawAccounts.push(current); + current = null; + inAccounts = false; + } else { + continue; + } + } + + const ci = trimmed.indexOf(":"); + if (ci < 0) continue; + const key = trimmed.slice(0, ci).trim(); + const val = stripQuotes(trimmed.slice(ci + 1).trim()); + if (val === "null" || val === "") continue; + + switch (key) { + case "default_theme": config.default_theme = val; break; + case "default_color": config.default_color = val; break; + case "default_publish_method": config.default_publish_method = val; break; + case "default_author": config.default_author = val; break; + 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; + } + } + + if (current) rawAccounts.push(current); + + if (rawAccounts.length > 0) { + config.accounts = rawAccounts.map(a => ({ + name: a.name || "", + alias: a.alias || "", + default: a.default === "true" || a.default === "1", + default_publish_method: a.default_publish_method || undefined, + default_author: a.default_author || undefined, + need_open_comment: a.need_open_comment ? toBool01(a.need_open_comment) : undefined, + only_fans_can_comment: a.only_fans_can_comment ? toBool01(a.only_fans_can_comment) : undefined, + app_id: a.app_id || undefined, + app_secret: a.app_secret || undefined, + chrome_profile_path: a.chrome_profile_path || undefined, + })); + } + + return config; +} + +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"), + "baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md" + ), + path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"), + ]; + for (const p of paths) { + try { + const content = fs.readFileSync(p, "utf-8"); + return parseWechatExtend(content); + } catch { + continue; + } + } + return {}; +} + +function selectAccount(config: WechatExtendConfig, alias?: string): WechatAccount | undefined { + if (!config.accounts || config.accounts.length === 0) return undefined; + if (alias) return config.accounts.find(a => a.alias === alias); + if (config.accounts.length === 1) return config.accounts[0]; + return config.accounts.find(a => a.default); +} + +export function resolveAccount(config: WechatExtendConfig, alias?: string): ResolvedAccount { + const acct = selectAccount(config, alias); + return { + name: acct?.name, + alias: acct?.alias, + default_publish_method: acct?.default_publish_method ?? config.default_publish_method, + default_author: acct?.default_author ?? config.default_author, + need_open_comment: acct?.need_open_comment ?? config.need_open_comment ?? 1, + only_fans_can_comment: acct?.only_fans_can_comment ?? config.only_fans_can_comment ?? 0, + app_id: acct?.app_id, + app_secret: acct?.app_secret, + chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path, + }; +} + +function loadEnvFile(envPath: string): Record { + const env: Record = {}; + if (!fs.existsSync(envPath)) return env; + const content = fs.readFileSync(envPath, "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx > 0) { + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + } + return env; +} + +function aliasToEnvKey(alias: string): string { + return alias.toUpperCase().replace(/-/g, "_"); +} + +export function loadCredentials(account?: ResolvedAccount): { appId: string; appSecret: string } { + if (account?.app_id && account?.app_secret) { + return { appId: account.app_id, appSecret: account.app_secret }; + } + + const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env"); + const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env"); + const cwdEnv = loadEnvFile(cwdEnvPath); + const homeEnv = loadEnvFile(homeEnvPath); + + const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : ""; + + let appId = ""; + let appSecret = ""; + + if (prefix) { + appId = process.env[`${prefix}APP_ID`] + || cwdEnv[`${prefix}APP_ID`] + || homeEnv[`${prefix}APP_ID`] + || ""; + appSecret = process.env[`${prefix}APP_SECRET`] + || cwdEnv[`${prefix}APP_SECRET`] + || homeEnv[`${prefix}APP_SECRET`] + || ""; + } + + if (!appId) { + appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID || ""; + } + if (!appSecret) { + appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET || ""; + } + + if (!appId || !appSecret) { + const hint = account?.alias ? ` (account: ${account.alias})` : ""; + throw new Error( + `Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` + + "Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file." + ); + } + + return { appId, appSecret }; +} + +export function listAccounts(config: WechatExtendConfig): string[] { + return (config.accounts || []).map(a => a.alias); +}