Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,14 @@ codex --version

If `codex --version` still fails, fix that before attempting `weixin:login` or `weixin:serve`.

After login or any service restart, use the non-sensitive health check:

```bash
npm run weixin:status
```

It reports the selected account, service lock/PID, and last recorded connection state without exposing tokens. If it reports `reauthorization_required`, run `npm run weixin:login` and confirm the new QR code.

### Linux

```bash
Expand Down Expand Up @@ -567,6 +575,8 @@ That file is the stable place to adjust:
- optional OpenAI-compatible provider keys such as `DEEPSEEK_*`, `MINIMAX_*`, `QWEN_*`, `OPENROUTER_*`, or `CODEX_COMPAT_*`
- `CODEXBRIDGE_DEBUG_WEIXIN`

When more than one Weixin account is saved, a successful `weixin:login` marks the new account as active. Leave `WEIXIN_ACCOUNT_ID` blank to use that active account, or set it explicitly only when intentionally overriding it.

### Windows Scheduled Task

Install and start a hidden per-user scheduled task:
Expand Down
3 changes: 2 additions & 1 deletion config/examples/weixin.service.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# applies this service env file as an override layer. Provider API keys and
# default models can therefore live in the repo-local `.env`.

# Explicit account selection. If omitted, the runtime auto-picks the only saved account.
# Explicit account selection. If omitted, the runtime uses the account selected by
# the most recent successful `weixin:login`, then falls back to the only saved account.
WEIXIN_ACCOUNT_ID=

# Policy
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"codex-provider:test": "tsx --test packages/codex-provider-relay/test/*.test.ts",
"codex-provider:typecheck": "tsc -p packages/codex-provider-relay/tsconfig.json --noEmit",
"weixin:login": "tsx src/cli.ts weixin login",
"weixin:status": "tsx src/cli.ts weixin status",
"weixin:clear-context": "tsx src/cli.ts weixin clear-context",
"weixin:serve": "tsx src/cli.ts weixin serve",
"codex:cleanup-internal-threads": "tsx src/cli.ts codex cleanup-internal-threads",
Expand Down
27 changes: 25 additions & 2 deletions scripts/service/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,29 @@ require_unit_installed() {
fi
}

find_active_weixin_account_id() {
local accounts_dir="${STATE_DIR}/weixin/accounts"
local active_account_file="${accounts_dir}/active-account.json"
if [[ ! -f "${active_account_file}" ]]; then
return
fi
python3 - "${active_account_file}" "${accounts_dir}" <<'PY'
import json
import os
import sys

active_file, accounts_dir = sys.argv[1:]
try:
with open(active_file, encoding="utf-8") as handle:
account_id = str(json.load(handle).get("account_id", "")).strip()
except (OSError, ValueError, TypeError):
account_id = ""

if account_id and os.path.isfile(os.path.join(accounts_dir, f"{account_id}.json")):
print(account_id, end="")
PY
}

find_single_weixin_account_id() {
local accounts_dir="${STATE_DIR}/weixin/accounts"
local candidates=()
Expand All @@ -91,7 +114,7 @@ find_single_weixin_account_id() {
local basename
basename="$(basename "${file}")"
case "${basename}" in
*.context-tokens.json|*.sync.json) continue ;;
active-account.json|*.context-tokens.json|*.sync.json) continue ;;
*.json) candidates+=("${basename%.json}") ;;
esac
done < <(find "${accounts_dir}" -maxdepth 1 -type f -name '*.json' -print0 | sort -z)
Expand All @@ -112,7 +135,7 @@ ensure_service_env_file() {
fi

local account_id
account_id="$(find_single_weixin_account_id || true)"
account_id="$(find_active_weixin_account_id || find_single_weixin_account_id || true)"

cat > "${SERVICE_ENV_FILE}" <<EOF
# Generated by scripts/service/install-systemd-user.sh
Expand Down
26 changes: 25 additions & 1 deletion scripts/service/install-launchd-user.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,36 @@ fi

mkdir -p "${PLIST_DIR}" "${CONFIG_DIR}" "${LOG_DIR}"

find_active_weixin_account_id() {
local accounts_dir="${STATE_DIR}/weixin/accounts"
local active_account_file="${accounts_dir}/active-account.json"
if [[ ! -f "${active_account_file}" ]]; then
return
fi
python3 - "${active_account_file}" "${accounts_dir}" <<'PY'
import json
import os
import sys

active_file, accounts_dir = sys.argv[1:]
try:
with open(active_file, encoding="utf-8") as handle:
account_id = str(json.load(handle).get("account_id", "")).strip()
except (OSError, ValueError, TypeError):
account_id = ""

if account_id and os.path.isfile(os.path.join(accounts_dir, f"{account_id}.json")):
print(account_id, end="")
PY
}

if [[ ! -f "${ENV_FILE}" ]]; then
ACTIVE_ACCOUNT_ID="$(find_active_weixin_account_id)"
cat > "${ENV_FILE}" <<EOF
# Generated by scripts/service/install-launchd-user.sh
# Safe to edit after install.

WEIXIN_ACCOUNT_ID=
WEIXIN_ACCOUNT_ID=${ACTIVE_ACCOUNT_ID}
WEIXIN_DM_POLICY=open
WEIXIN_GROUP_POLICY=disabled

Expand Down
18 changes: 17 additions & 1 deletion scripts/service/install-windows-task.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ function Resolve-ServiceAppData([string]$ResolvedHomeDir) {
return Join-Path $ResolvedHomeDir "AppData\Roaming"
}

function Find-ActiveWeixinAccountId([string]$ResolvedStateDir) {
$AccountsDir = Join-Path $ResolvedStateDir "weixin\accounts"
$ActiveAccountPath = Join-Path $AccountsDir "active-account.json"
if (-not (Test-Path $ActiveAccountPath)) {
return ""
}
try {
$AccountId = [string]((Get-Content -Raw -Path $ActiveAccountPath | ConvertFrom-Json).account_id)
if ($AccountId -and (Test-Path (Join-Path $AccountsDir "$AccountId.json"))) {
return $AccountId.Trim()
}
} catch {}
return ""
}

$NodeBin = Find-CommandPath @("node.exe", "node")
if (-not $NodeBin) {
throw "node was not found in PATH"
Expand All @@ -91,6 +106,7 @@ $LogDir = Join-Path $StateDir "logs"
$StdoutLog = Join-Path $LogDir "weixin-bridge.out.log"
$StderrLog = Join-Path $LogDir "weixin-bridge.err.log"
$CodexHome = Join-Path $HomeDir ".codex"
$ActiveWeixinAccountId = Find-ActiveWeixinAccountId $StateDir

New-Item -ItemType Directory -Force -Path $ConfigDir, $LogDir | Out-Null

Expand All @@ -99,7 +115,7 @@ if (-not (Test-Path $EnvFile)) {
"# Generated by scripts/service/install-windows-task.ps1",
"# Safe to edit after install.",
"",
"WEIXIN_ACCOUNT_ID=",
"WEIXIN_ACCOUNT_ID=$ActiveWeixinAccountId",
"WEIXIN_DM_POLICY=open",
"WEIXIN_GROUP_POLICY=disabled",
"",
Expand Down
45 changes: 44 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import QRCode from 'qrcode';
import { WeixinAccountStore } from './platforms/weixin/account_store.js';
import { WEIXIN_DEFAULT_BASE_URL, defaultCodexBridgeStateDir } from './platforms/weixin/config.js';
import { readWeixinConnectionStatus } from './platforms/weixin/connection_status.js';
import { WEIXIN_DEFAULT_BASE_URL, defaultCodexBridgeStateDir, loadWeixinConfig } from './platforms/weixin/config.js';
import { WeixinPlatformPlugin } from './platforms/weixin/plugin.js';
import { DEFAULT_ILINK_BOT_TYPE, officialQrLogin } from './platforms/weixin/official/login.js';
import { clearContextTokensForAccount } from './platforms/weixin/official/context_tokens.js';
Expand Down Expand Up @@ -98,6 +99,9 @@ async function main(argv: string[] = process.argv.slice(2)) {
if (group === 'weixin' && command === 'clear-context') {
return runWeixinClearContext(args);
}
if (group === 'weixin' && command === 'status') {
return runWeixinStatus(args);
}
if (group === 'codex' && command === 'cleanup-internal-threads') {
return runCodexCleanupInternalThreads(args);
}
Expand Down Expand Up @@ -196,6 +200,32 @@ async function runWeixinClearContext(args: string[]) {
process.stdout.write(`${i18n.t('cli.clearContext.account', { value: accountId })}\n`);
}

function runWeixinStatus(args: string[]) {
const options = parseWeixinStatusArgs(args);
const stateDir = path.resolve(options.stateDir ?? defaultCodexBridgeStateDir());
const accountsDir = path.join(stateDir, 'weixin', 'accounts');
const accountStore = new WeixinAccountStore({ rootDir: accountsDir });
const config = loadWeixinConfig({ stateDir, accountStore });
const lockPath = path.join(stateDir, 'runtime', 'weixin-serve.lock');
const lock = readServeLock(lockPath);
const report = {
stateDir,
account: {
activeAccountId: accountStore.getActiveAccount(),
configuredAccountId: config.accountId,
savedAccountIds: accountStore.listAccounts(),
},
service: {
lockPath,
running: Boolean(lock?.pid && isProcessAlive(lock.pid)),
pid: lock?.pid ?? null,
startedAt: lock?.startedAt ?? null,
},
connection: readWeixinConnectionStatus(stateDir),
};
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}

async function runWeixinServe(args: string[]) {
const i18n = createI18n();
const options = parseWeixinServeArgs(args);
Expand Down Expand Up @@ -533,6 +563,17 @@ function parseWeixinLoginArgs(args: string[]): WeixinLoginArgs {
return options;
}

function parseWeixinStatusArgs(args: string[]) {
const options: { stateDir: string | null } = { stateDir: null };
for (let index = 0; index < args.length; index += 1) {
if (args[index] === '--state-dir' && args[index + 1]) {
options.stateDir = args[index + 1] ?? null;
index += 1;
}
}
return options;
}

function parseWeixinServeArgs(args: string[]): WeixinServeArgs {
const options: WeixinServeArgs = {
stateDir: null,
Expand Down Expand Up @@ -947,6 +988,7 @@ function printUsage() {
process.stdout.write([
createI18n().t('cli.usage.title'),
createI18n().t('cli.usage.login'),
createI18n().t('cli.usage.status'),
createI18n().t('cli.usage.clearContext'),
createI18n().t('cli.usage.serve'),
createI18n().t('cli.usage.cleanupInternalThreads'),
Expand Down Expand Up @@ -1113,6 +1155,7 @@ export {
resolveEmbeddedCodexNativeApiOptions,
parseWeixinClearContextArgs,
parseWeixinLoginArgs,
parseWeixinStatusArgs,
parseWeixinServeArgs,
readPendingRestartNotifications,
resolveClearContextAccountId,
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,7 @@ const CATALOGS: Record<SupportedLocale, MessageCatalog> = {
'cli.nativeApiServe.stopping': '正在停止 Codex Native API localhost 服务:{signal}',
'cli.usage.title': '用法:',
'cli.usage.login': ' npm run weixin:login -- [--base-url URL] [--state-dir DIR] [--bot-type N] [--timeout-sec N]',
'cli.usage.status': ' npm run weixin:status -- [--state-dir DIR]',
'cli.usage.clearContext': ' npm run weixin:clear-context -- [--state-dir DIR] [--account-id ID]',
'cli.usage.serve': ' npm run weixin:serve -- [--state-dir DIR] [--cwd DIR]',
'cli.usage.cleanupInternalThreads': ' npm run codex:cleanup-internal-threads -- [--state-dir DIR] [--cwd DIR] [--limit N] [--dry-run|--apply]',
Expand Down Expand Up @@ -2340,6 +2341,7 @@ const CATALOGS: Record<SupportedLocale, MessageCatalog> = {
'cli.nativeApiServe.stopping': 'Stopping the Codex Native API localhost service: {signal}',
'cli.usage.title': 'Usage:',
'cli.usage.login': ' npm run weixin:login -- [--base-url URL] [--state-dir DIR] [--bot-type N] [--timeout-sec N]',
'cli.usage.status': ' npm run weixin:status -- [--state-dir DIR]',
'cli.usage.clearContext': ' npm run weixin:clear-context -- [--state-dir DIR] [--account-id ID]',
'cli.usage.serve': ' npm run weixin:serve -- [--state-dir DIR] [--cwd DIR]',
'cli.usage.cleanupInternalThreads': ' npm run codex:cleanup-internal-threads -- [--state-dir DIR] [--cwd DIR] [--limit N] [--dry-run|--apply]',
Expand Down
26 changes: 26 additions & 0 deletions src/platforms/weixin/account_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export interface SavedWeixinAccount {
}

type ContextTokenMap = Record<string, string>;
type ActiveAccountRecord = {
account_id: string;
selected_at: string;
};
export class WeixinAccountStore {
constructor({ rootDir = defaultWeixinAccountsDir() } = {}) {
this.rootDir = rootDir;
Expand All @@ -27,6 +31,7 @@ export class WeixinAccountStore {
const entries = fs.readdirSync(this.rootDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.filter((entry) => entry.name !== 'active-account.json')
.filter((entry) => !entry.name.endsWith('.context-tokens.json'))
.filter((entry) => !entry.name.endsWith('.sync.json'))
.map((entry) => entry.name.slice(0, -'.json'.length))
Expand All @@ -48,6 +53,23 @@ export class WeixinAccountStore {
return this.readJson<SavedWeixinAccount>(this.accountFile(accountId));
}

setActiveAccount(accountId: string) {
const normalizedAccountId = String(accountId ?? '').trim();
if (!normalizedAccountId) {
return;
}
this.writeJson(this.activeAccountFile(), {
account_id: normalizedAccountId,
selected_at: new Date().toISOString(),
} satisfies ActiveAccountRecord);
}

getActiveAccount() {
const record = this.readJson<ActiveAccountRecord>(this.activeAccountFile());
const accountId = typeof record?.account_id === 'string' ? record.account_id.trim() : '';
return accountId || null;
}

getContextToken(accountId: string, peerId: string) {
const tokens = this.readJson<ContextTokenMap>(this.contextTokensFile(accountId)) ?? {};
const token = tokens?.[peerId];
Expand All @@ -72,6 +94,10 @@ export class WeixinAccountStore {
return path.join(this.rootDir, `${accountId}.json`);
}

activeAccountFile() {
return path.join(this.rootDir, 'active-account.json');
}

contextTokensFile(accountId: string) {
return path.join(this.rootDir, `${accountId}.context-tokens.json`);
}
Expand Down
9 changes: 7 additions & 2 deletions src/platforms/weixin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ export function loadWeixinConfig({
} = {}): WeixinConfig {
let accountId = normalizeString(env.WEIXIN_ACCOUNT_ID);
if (!accountId) {
const accountIds = accountStore.listAccounts();
if (accountIds.length === 1) {
const activeAccountId = accountStore.getActiveAccount();
if (activeAccountId && accountStore.loadAccount(activeAccountId)) {
accountId = activeAccountId;
} else {
const accountIds = accountStore.listAccounts();
if (accountIds.length === 1) {
[accountId] = accountIds;
}
}
}

Expand Down
54 changes: 54 additions & 0 deletions src/platforms/weixin/connection_status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from 'node:fs';
import path from 'node:path';

export type WeixinConnectionState = 'connected' | 'reauthorization_required' | 'stopped';

export interface WeixinConnectionStatus {
accountId: string | null;
state: WeixinConnectionState;
updatedAt: string;
errorCode: number | null;
}

export function weixinConnectionStatusFile(stateDir: string) {
return path.join(stateDir, 'runtime', 'weixin-connection-status.json');
}

export function writeWeixinConnectionStatus({
stateDir,
accountId,
state,
errorCode = null,
}: Omit<WeixinConnectionStatus, 'updatedAt'> & { stateDir: string }) {
const status: WeixinConnectionStatus = {
accountId: accountId ? String(accountId) : null,
state,
updatedAt: new Date().toISOString(),
errorCode,
};
const filePath = weixinConnectionStatusFile(stateDir);
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
} catch {}
return status;
}

export function readWeixinConnectionStatus(stateDir: string): WeixinConnectionStatus | null {
const filePath = weixinConnectionStatusFile(stateDir);
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial<WeixinConnectionStatus>;
const state = parsed.state;
if (state !== 'connected' && state !== 'reauthorization_required' && state !== 'stopped') {
return null;
}
return {
accountId: typeof parsed.accountId === 'string' && parsed.accountId ? parsed.accountId : null,
state,
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
errorCode: typeof parsed.errorCode === 'number' ? parsed.errorCode : null,
};
} catch {
return null;
}
}
Loading