From b914f0947ac8de3b8dd654423452a30f834fc1ec Mon Sep 17 00:00:00 2001
From: eric
Date: Tue, 24 Mar 2026 13:16:18 -0500
Subject: [PATCH] ''
---
.gitignore | 2 +
config/wechat_chatbot.example.json | 10 +
electron/package.json | 7 +-
electron/pnpm-lock.yaml | 8 +
electron/src/main/clientDb.ts | 226 ++++++++++++++
electron/src/main/index.ts | 68 ++++
electron/src/preload/index.ts | 8 +
electron/src/renderer/src/App.css | 149 +++++++++
electron/src/renderer/src/App.tsx | 62 ++--
.../src/renderer/src/NetworkDashboard.tsx | 183 ++++++-----
electron/src/renderer/src/ProxySettings.tsx | 10 +-
electron/src/renderer/src/RemoteBinding.tsx | 52 +---
electron/src/renderer/src/SystemMetrics.tsx | 11 +-
electron/src/renderer/src/WeChatChatbot.tsx | 284 +++++++++++++++++
.../src/renderer/src/WorkspaceDashboard.tsx | 226 ++++++++++++++
electron/src/renderer/src/vite-env.d.ts | 12 +
electron/src/renderer/src/workspace.ts | 40 +++
integrations/openclaw-wechat/README.md | 20 ++
skills/wechat-chatbot/SKILL.md | 39 +++
web2.py | 2 +
web2_wechat_chatbot.py | 292 ++++++++++++++++++
wechat_chatbot_cli.py | 141 +++++++++
22 files changed, 1697 insertions(+), 155 deletions(-)
create mode 100644 config/wechat_chatbot.example.json
create mode 100644 electron/src/main/clientDb.ts
create mode 100644 electron/src/renderer/src/WeChatChatbot.tsx
create mode 100644 electron/src/renderer/src/WorkspaceDashboard.tsx
create mode 100644 electron/src/renderer/src/workspace.ts
create mode 100644 integrations/openclaw-wechat/README.md
create mode 100644 skills/wechat-chatbot/SKILL.md
create mode 100644 web2_wechat_chatbot.py
create mode 100644 wechat_chatbot_cli.py
diff --git a/.gitignore b/.gitignore
index fc68b19..0b7997c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -172,6 +172,8 @@ config/youtube_oauth_token.json
config/web2_api_token.txt
config/web2_bind_host.txt
config/easytier_network.json
+config/wechat_chatbot.json
+config/wechat_chatbot.state.json
electron/resources/easytier/easytier-core.exe
electron/resources/easytier/_extract_tmp/
diff --git a/config/wechat_chatbot.example.json b/config/wechat_chatbot.example.json
new file mode 100644
index 0000000..120fb25
--- /dev/null
+++ b/config/wechat_chatbot.example.json
@@ -0,0 +1,10 @@
+{
+ "apiKey": "wc_live_xxxxxxxx",
+ "proxyUrl": "http://your-proxy-server:3000",
+ "deviceType": "ipad",
+ "proxy": "10",
+ "accountId": "default",
+ "webhookHost": "",
+ "webhookPort": 18790,
+ "webhookPath": "/webhook/wechat"
+}
diff --git a/electron/package.json b/electron/package.json
index 84ef031..fdc5ca9 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -15,7 +15,8 @@
"dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "sql.js": "^1.14.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
@@ -52,7 +53,9 @@
{
"from": "resources/easytier",
"to": "easytier",
- "filter": ["**/*"]
+ "filter": [
+ "**/*"
+ ]
},
{
"from": "..",
diff --git a/electron/pnpm-lock.yaml b/electron/pnpm-lock.yaml
index 7b23575..b64bd71 100644
--- a/electron/pnpm-lock.yaml
+++ b/electron/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ sql.js:
+ specifier: ^1.14.1
+ version: 1.14.1
devDependencies:
'@types/node':
specifier: ^22.10.0
@@ -1793,6 +1796,9 @@ packages:
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
+ sql.js@1.14.1:
+ resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
+
ssri@9.0.1:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -3920,6 +3926,8 @@ snapshots:
sprintf-js@1.1.3:
optional: true
+ sql.js@1.14.1: {}
+
ssri@9.0.1:
dependencies:
minipass: 3.3.6
diff --git a/electron/src/main/clientDb.ts b/electron/src/main/clientDb.ts
new file mode 100644
index 0000000..8837d7b
--- /dev/null
+++ b/electron/src/main/clientDb.ts
@@ -0,0 +1,226 @@
+/**
+ * 客户端本地 SQLite(sql.js):直播事件、用户操作、运行日志、数据快照。
+ * 数据库文件:userData/client-data.sqlite
+ */
+import { createRequire } from 'module'
+import fs from 'fs'
+import path from 'path'
+import { app } from 'electron'
+import initSqlJs, { type Database } from 'sql.js'
+
+let db: Database | null = null
+let persistTimer: ReturnType | null = null
+
+export function getDbPath(): string {
+ return path.join(app.getPath('userData'), 'client-data.sqlite')
+}
+
+function schedulePersist(): void {
+ if (persistTimer) clearTimeout(persistTimer)
+ persistTimer = setTimeout(() => {
+ persistTimer = null
+ flushClientDb()
+ }, 400)
+}
+
+export function flushClientDb(): void {
+ if (!db) return
+ try {
+ const buf = Buffer.from(db.export())
+ fs.mkdirSync(path.dirname(getDbPath()), { recursive: true })
+ fs.writeFileSync(getDbPath(), buf)
+ } catch {
+ /* ignore */
+ }
+}
+
+function runMigrations(): void {
+ if (!db) return
+ db.run(`
+ CREATE TABLE IF NOT EXISTS live_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ process TEXT,
+ event_type TEXT NOT NULL,
+ status TEXT,
+ message TEXT,
+ detail_json TEXT
+ );
+ CREATE TABLE IF NOT EXISTS user_actions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ action TEXT NOT NULL,
+ nav TEXT,
+ detail_json TEXT
+ );
+ CREATE TABLE IF NOT EXISTS client_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ level TEXT NOT NULL,
+ source TEXT,
+ message TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS data_snapshots (
+ key TEXT PRIMARY KEY,
+ json TEXT NOT NULL,
+ ts INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_live_ts ON live_events(ts DESC);
+ CREATE INDEX IF NOT EXISTS idx_user_ts ON user_actions(ts DESC);
+ CREATE INDEX IF NOT EXISTS idx_client_ts ON client_logs(ts DESC);
+ `)
+}
+
+export async function initClientDb(): Promise {
+ const require = createRequire(import.meta.url)
+ // sql.js exports "." and "./dist/*" only — not package.json
+ const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm')
+ const SQL = await initSqlJs({ locateFile: () => wasmPath })
+ const dbPath = getDbPath()
+ if (fs.existsSync(dbPath)) {
+ const fileBuf = fs.readFileSync(dbPath)
+ db = new SQL.Database(fileBuf)
+ } else {
+ db = new SQL.Database()
+ }
+ runMigrations()
+ schedulePersist()
+}
+
+export function appendLiveEvent(payload: {
+ eventType: string
+ process?: string
+ status?: string
+ message?: string
+ detail?: unknown
+}): void {
+ if (!db) return
+ const ts = Date.now()
+ const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null
+ db.run(
+ `INSERT INTO live_events (ts, process, event_type, status, message, detail_json) VALUES (?, ?, ?, ?, ?, ?)`,
+ [
+ ts,
+ payload.process ?? null,
+ payload.eventType,
+ payload.status ?? null,
+ payload.message ?? null,
+ detailJson,
+ ],
+ )
+ schedulePersist()
+}
+
+export function appendUserAction(payload: { action: string; nav?: string; detail?: unknown }): void {
+ if (!db) return
+ const ts = Date.now()
+ const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null
+ db.run(`INSERT INTO user_actions (ts, action, nav, detail_json) VALUES (?, ?, ?, ?)`, [
+ ts,
+ payload.action,
+ payload.nav ?? null,
+ detailJson,
+ ])
+ schedulePersist()
+}
+
+export function appendClientLog(level: string, source: string, message: string): void {
+ if (!db) return
+ const ts = Date.now()
+ db.run(`INSERT INTO client_logs (ts, level, source, message) VALUES (?, ?, ?, ?)`, [
+ ts,
+ level,
+ source,
+ message,
+ ])
+ schedulePersist()
+}
+
+export function saveDataSnapshot(key: string, json: unknown): void {
+ if (!db) return
+ const ts = Date.now()
+ const text = JSON.stringify(json)
+ db.run(`INSERT OR REPLACE INTO data_snapshots (key, json, ts) VALUES (?, ?, ?)`, [key, text, ts])
+ schedulePersist()
+}
+
+export type DashboardRow = {
+ id: number
+ ts: number
+ process?: string | null
+ event_type?: string
+ status?: string | null
+ message?: string | null
+ detail_json?: string | null
+ action?: string
+ nav?: string | null
+ level?: string
+ source?: string | null
+}
+
+function execToRows(sql: string): DashboardRow[] {
+ if (!db) return []
+ try {
+ const res = db.exec(sql)
+ if (!res.length) return []
+ const { columns, values } = res[0]
+ return values.map((row) => {
+ const o: Record = {}
+ columns.forEach((c, i) => {
+ o[c] = row[i]
+ })
+ return o as DashboardRow
+ })
+ } catch {
+ return []
+ }
+}
+
+export function getDashboardSnapshot(): {
+ liveEvents: DashboardRow[]
+ userActions: DashboardRow[]
+ clientLogs: DashboardRow[]
+ snapshots: Record
+} {
+ const empty = {
+ liveEvents: [] as DashboardRow[],
+ userActions: [] as DashboardRow[],
+ clientLogs: [] as DashboardRow[],
+ snapshots: {} as Record,
+ }
+ if (!db) return empty
+
+ try {
+ empty.liveEvents = execToRows(
+ `SELECT id, ts, process, event_type, status, message, detail_json FROM live_events ORDER BY id DESC LIMIT 80`,
+ )
+ empty.userActions = execToRows(
+ `SELECT id, ts, action, nav, detail_json FROM user_actions ORDER BY id DESC LIMIT 80`,
+ )
+ empty.clientLogs = execToRows(
+ `SELECT id, ts, level, source, message FROM client_logs ORDER BY id DESC LIMIT 120`,
+ )
+
+ const snapRes = db.exec(`SELECT key, json, ts FROM data_snapshots`)
+ if (snapRes.length) {
+ const { columns, values } = snapRes[0]
+ for (const row of values) {
+ const o: Record = {}
+ columns.forEach((c, i) => {
+ o[c] = row[i]
+ })
+ const key = String(o.key ?? '')
+ const json = String(o.json ?? '')
+ const ts = Number(o.ts ?? 0)
+ try {
+ empty.snapshots[key] = { ts, data: JSON.parse(json) as unknown }
+ } catch {
+ empty.snapshots[key] = { ts, data: json }
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return empty
+}
diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts
index faba286..74f4c1d 100644
--- a/electron/src/main/index.ts
+++ b/electron/src/main/index.ts
@@ -15,6 +15,15 @@ import {
startBundledEasyTier,
stopBundledEasyTier,
} from './easytier'
+import {
+ appendClientLog,
+ appendLiveEvent,
+ appendUserAction,
+ flushClientDb,
+ getDashboardSnapshot,
+ initClientDb,
+ saveDataSnapshot,
+} from './clientDb'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -371,6 +380,9 @@ async function createWindow(): Promise {
},
})
mainWindow.once('ready-to-show', () => mainWindow?.show())
+ mainWindow.webContents.once('did-finish-load', () => {
+ appendClientLog('info', 'main', '渲染界面已加载')
+ })
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
@@ -432,6 +444,58 @@ if (!gotLock) {
})
app.whenReady().then(async () => {
+ try {
+ await initClientDb()
+ appendClientLog('info', 'main', '客户端主进程就绪')
+ } catch (e) {
+ console.error('[clientDb]', e)
+ }
+
+ ipcMain.removeHandler('workspace:log-live')
+ ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => {
+ if (!payload || typeof payload !== 'object') return
+ const p = payload as Record
+ const eventType = typeof p.eventType === 'string' ? p.eventType : 'unknown'
+ appendLiveEvent({
+ eventType,
+ process: typeof p.process === 'string' ? p.process : undefined,
+ status: typeof p.status === 'string' ? p.status : undefined,
+ message: typeof p.message === 'string' ? p.message : undefined,
+ detail: p.detail,
+ })
+ })
+
+ ipcMain.removeHandler('workspace:log-user')
+ ipcMain.handle('workspace:log-user', (_evt, payload: unknown) => {
+ if (!payload || typeof payload !== 'object') return
+ const p = payload as Record
+ const action = typeof p.action === 'string' ? p.action : 'unknown'
+ appendUserAction({
+ action,
+ nav: typeof p.nav === 'string' ? p.nav : undefined,
+ detail: p.detail,
+ })
+ })
+
+ ipcMain.removeHandler('workspace:log-client')
+ ipcMain.handle('workspace:log-client', (_evt, payload: unknown) => {
+ if (!payload || typeof payload !== 'object') return
+ const p = payload as Record
+ const level = typeof p.level === 'string' ? p.level : 'info'
+ const source = typeof p.source === 'string' ? p.source : 'renderer'
+ const message = typeof p.message === 'string' ? p.message : ''
+ if (message) appendClientLog(level, source, message)
+ })
+
+ ipcMain.removeHandler('workspace:save-snapshot')
+ ipcMain.handle('workspace:save-snapshot', (_evt, key: unknown, data: unknown) => {
+ if (typeof key !== 'string' || !key.trim()) return
+ saveDataSnapshot(key.trim(), data)
+ })
+
+ ipcMain.removeHandler('workspace:get-dashboard')
+ ipcMain.handle('workspace:get-dashboard', () => getDashboardSnapshot())
+
ipcMain.removeHandler('open-external')
ipcMain.handle('open-external', async (_evt, url: unknown) => {
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
@@ -547,6 +611,8 @@ if (!gotLock) {
startBundledEasyTier(backendRoot)
+ appendClientLog('info', 'main', '本地服务已连接,准备打开界面')
+
await createWindow()
app.on('activate', () => {
@@ -561,6 +627,8 @@ if (!gotLock) {
})
app.on('before-quit', () => {
+ appendClientLog('info', 'main', '应用退出')
+ flushClientDb()
stopBundledEasyTier()
killPythonTree()
})
diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts
index a5b66bd..1ac571a 100644
--- a/electron/src/preload/index.ts
+++ b/electron/src/preload/index.ts
@@ -16,3 +16,11 @@ contextBridge.exposeInMainWorld('remoteBinding', {
generateToken: () => ipcRenderer.invoke('remote:generate-token'),
getBindPayload: (selectedIp: string) => ipcRenderer.invoke('remote:get-bind-payload', selectedIp),
})
+
+contextBridge.exposeInMainWorld('workspace', {
+ logLiveEvent: (payload: unknown) => ipcRenderer.invoke('workspace:log-live', payload),
+ logUserAction: (payload: unknown) => ipcRenderer.invoke('workspace:log-user', payload),
+ logClient: (payload: unknown) => ipcRenderer.invoke('workspace:log-client', payload),
+ saveSnapshot: (key: string, data: unknown) => ipcRenderer.invoke('workspace:save-snapshot', key, data),
+ getDashboard: () => ipcRenderer.invoke('workspace:get-dashboard'),
+})
diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css
index 51024b1..bd2dddd 100644
--- a/electron/src/renderer/src/App.css
+++ b/electron/src/renderer/src/App.css
@@ -1185,6 +1185,150 @@ body.ready {
gap: 1rem;
}
+.app-panel--workspace {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.card--workspace {
+ border-color: rgba(110, 168, 254, 0.25);
+ background: linear-gradient(165deg, rgba(110, 168, 254, 0.06) 0%, var(--bg-card) 100%);
+}
+
+.ws-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.ws-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.ws-card {
+ padding: 0.75rem 1rem;
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.22);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.ws-card__title {
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0 0 0.35rem;
+ color: #e8eaed;
+}
+
+.ws-card__meta {
+ margin: 0 0 0.5rem;
+ font-size: 11px;
+ color: var(--text-muted, #8b929e);
+}
+
+.ws-card__empty {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-muted, #8b929e);
+}
+
+.ws-snap-grid {
+ display: grid;
+ gap: 0.5rem;
+ font-size: 12px;
+}
+
+.ws-snap-k {
+ display: block;
+ color: var(--text-muted, #8b929e);
+ font-size: 11px;
+}
+
+.ws-snap-v {
+ font-weight: 600;
+ margin-right: 0.35rem;
+}
+
+.ws-snap-meta {
+ color: #b8c0cc;
+ font-size: 11px;
+}
+
+.ws-section {
+ margin-top: 1rem;
+}
+
+.ws-section__title {
+ margin: 0 0 0.5rem;
+ font-size: 14px;
+ font-weight: 600;
+ color: #e8eaed;
+}
+
+.ws-table-wrap {
+ overflow: auto;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.ws-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.ws-table th,
+.ws-table td {
+ padding: 0.45rem 0.6rem;
+ text-align: left;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.ws-table th {
+ color: var(--text-muted, #8b929e);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.ws-table tr:last-child td {
+ border-bottom: none;
+}
+
+.ws-mono {
+ font-family: ui-monospace, monospace;
+ font-size: 11px;
+}
+
+.ws-cell-clip {
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ws-empty {
+ margin: 0;
+ padding: 0.75rem;
+ font-size: 12px;
+ color: var(--text-muted, #8b929e);
+}
+
+.ws-pre {
+ margin: 0;
+ font-size: 11px;
+ line-height: 1.4;
+ max-height: 160px;
+ overflow: auto;
+ color: #b8c0cc;
+}
+
.card--remote {
border-color: rgba(100, 149, 237, 0.28);
background: linear-gradient(165deg, rgba(100, 149, 237, 0.07) 0%, var(--bg-card) 100%);
@@ -1254,6 +1398,11 @@ body.ready {
margin-bottom: 0.75rem;
}
+.wc-proxy-param {
+ width: 7rem;
+ max-width: 100%;
+}
+
.remote-toggle-label {
display: flex;
align-items: center;
diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx
index ad441a0..1208ca5 100644
--- a/electron/src/renderer/src/App.tsx
+++ b/electron/src/renderer/src/App.tsx
@@ -4,18 +4,23 @@ import NetworkDashboard from './NetworkDashboard'
import RemoteBinding from './RemoteBinding'
import ProxySettings from './ProxySettings'
import SystemMetrics from './SystemMetrics'
+import WeChatChatbot from './WeChatChatbot'
+import WorkspaceDashboard from './WorkspaceDashboard'
+import { logLiveEvent, logUserAction } from './workspace'
import { UI_DROPDOWN_YOUTUBE_ONLY, UI_SHOW_YOUTUBE_GOOGLE_OAUTH } from './constants'
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils'
type Entry = { script: string; label: string; pm2: string }
-type NavId = 'record' | 'network' | 'proxy' | 'system'
+type NavId = 'desk' | 'record' | 'network' | 'proxy' | 'system' | 'wechat'
const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [
+ { id: 'desk', label: '工作台', hint: '事件与综合仪表盘' },
{ id: 'record', label: '录制', hint: '任务与推流控制' },
{ id: 'network', label: '网络', hint: '连通与测速' },
{ id: 'proxy', label: '代理', hint: '仅录制进程' },
{ id: 'system', label: '系统', hint: '负载与网速' },
+ { id: 'wechat', label: '微信', hint: 'Chatbot 与扫码' },
]
type StatusVariant =
@@ -45,13 +50,12 @@ export default function App() {
const [entries, setEntries] = useState([])
const [currentProcess, setCurrentProcess] = useState('')
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
- line: '请稍候…',
+ line: '—',
variant: 'loading',
})
- const [logText, setLogText] = useState('正在读取运行信息…')
+ const [logText, setLogText] = useState('')
const [bootError, setBootError] = useState(null)
const [emptyHint, setEmptyHint] = useState(null)
- const [initialLoad, setInitialLoad] = useState(true)
const [youtubeText, setYoutubeText] = useState('')
const [urlText, setUrlText] = useState('')
@@ -66,6 +70,7 @@ export default function App() {
const [fetchKeyLoading, setFetchKeyLoading] = useState(false)
const [nav, setNav] = useState('record')
+ const lastLiveStatusRef = useRef<{ process: string; status: string } | null>(null)
const logRef = useRef(null)
@@ -107,7 +112,6 @@ export default function App() {
const loadConfig = useCallback(
async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => {
- setSt('正在读取…')
try {
const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`)
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
@@ -139,7 +143,7 @@ export default function App() {
}, [])
const startYoutubeOAuth = useCallback(async () => {
- setYoutubeStatus('正在获取授权地址…')
+ setYoutubeStatus('')
try {
const res = await apiFetch(apiUrl('/youtube/oauth/start'))
const data = (await res.json()) as { url?: string; error?: string }
@@ -183,7 +187,6 @@ export default function App() {
setSt: (s: string) => void,
endLoading: () => void,
) => {
- setSt('正在保存…')
try {
const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, {
method: 'POST',
@@ -213,6 +216,11 @@ export default function App() {
if (action === 'status') {
const ps = data.process_status as string
+ const prev = lastLiveStatusRef.current
+ if (!prev || prev.process !== currentProcess || prev.status !== ps) {
+ lastLiveStatusRef.current = { process: currentProcess, status: ps }
+ logLiveEvent({ eventType: 'process_status', process: currentProcess, status: ps })
+ }
const { line, variant } = statusPresentation(ps)
setStatusMeta({ line, variant })
@@ -249,7 +257,13 @@ export default function App() {
.join('\n') || '已完成'
const actionLabel =
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
- setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}\n\n正在刷新状态…`)
+ logLiveEvent({
+ eventType: 'control',
+ process: currentProcess,
+ message: `${actionLabel} 已执行`,
+ detail: { action },
+ })
+ setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
setTimeout(() => void runPm2Action('status'), 1500)
}
} catch (err) {
@@ -266,13 +280,16 @@ export default function App() {
document.body.classList.add('ready')
}, [])
+ useEffect(() => {
+ logUserAction('navigate', nav)
+ }, [nav])
+
useEffect(() => {
let cancelled = false
;(async () => {
try {
const filtered = await loadProcessMonitor()
if (cancelled) return
- setInitialLoad(false)
if (!filtered.length) {
setEmptyHint(
UI_DROPDOWN_YOUTUBE_ONLY
@@ -285,7 +302,6 @@ export default function App() {
setEmptyHint(null)
setCurrentProcess(filtered[0].pm2)
} catch (e) {
- setInitialLoad(false)
setBootError(
`软件内部的录制服务没有启动成功。\n\n若您使用的是完整安装包,请尝试重启电脑后再打开;仍不行请联系技术支持。\n\n(技术信息:${e instanceof Error ? e.message : String(e)})`,
)
@@ -405,6 +421,11 @@ export default function App() {
+ {nav === 'desk' && (
+
+
+
+ )}
{nav === 'network' && (
@@ -421,6 +442,11 @@ export default function App() {
)}
+ {nav === 'wechat' && (
+
+
+
+ )}
{nav === 'record' && (
@@ -433,7 +459,7 @@ export default function App() {
id="processSelect"
className="select-process"
aria-label="选择要管理的录制任务"
- disabled={initialLoad || !!emptyHint}
+ disabled={!!emptyHint}
value={currentProcess}
onChange={(e) => onProcessChange(e.target.value)}
>
@@ -446,15 +472,7 @@ export default function App() {
- {initialLoad && (
-
- )}
-
- {!initialLoad && ent && (
+ {ent && (
当前任务
{ent.label}
@@ -472,7 +490,7 @@ export default function App() {
)}
- {!emptyHint && !initialLoad && (
+ {!emptyHint && (
{isYoutube && (
@@ -504,7 +522,7 @@ export default function App() {
title={!ytOauth?.connected ? '请先完成 Google 授权' : ''}
onClick={() => void fetchYoutubeStreamKey()}
>
- {fetchKeyLoading ? '拉取中…' : '拉取串流密钥并写入配置'}
+ 拉取串流密钥并写入配置
)}
diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx
index 4f578bd..80debbf 100644
--- a/electron/src/renderer/src/NetworkDashboard.tsx
+++ b/electron/src/renderer/src/NetworkDashboard.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { apiFetch, apiUrl } from './api'
+import { saveWorkspaceSnapshot } from './workspace'
type SiteInfo = {
host: string
@@ -31,6 +32,37 @@ type NetworkPayload = {
error?: string
}
+const EMPTY_SITE: SiteInfo = {
+ host: '',
+ reachable: false,
+ latency_ms: -1,
+ error: null,
+ resolved_ip: null,
+ country: null,
+ country_code: null,
+ geo_error: null,
+}
+
+const EMPTY_SPEED: SpeedBlock = {
+ label: '',
+ download_mbps: null,
+ download_error: null,
+ download_bytes_sampled: 0,
+ upload_mbps: null,
+ upload_error: null,
+ upload_target: null,
+}
+
+const DEFAULT_NETWORK: NetworkPayload = {
+ google: EMPTY_SITE,
+ douyin: EMPTY_SITE,
+ routes: { different: null, note: '' },
+ speed: {
+ overseas: EMPTY_SPEED,
+ domestic: EMPTY_SPEED,
+ },
+}
+
function fmtMbps(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
return `${v.toFixed(2)} Mbps`
@@ -48,7 +80,9 @@ function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
延迟
-
{site.latency_ms != null ? `${site.latency_ms.toFixed(0)} ms` : '—'}
+
+ {site.latency_ms != null && site.latency_ms >= 0 ? `${site.latency_ms.toFixed(0)} ms` : '—'}
+
解析 IP
{site.resolved_ip ?? '—'}
地区(参考)
@@ -68,11 +102,9 @@ function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
export default function NetworkDashboard() {
const [data, setData] = useState
(null)
- const [loading, setLoading] = useState(true)
const [err, setErr] = useState(null)
const load = useCallback(async () => {
- setLoading(true)
setErr(null)
try {
const res = await apiFetch(apiUrl('/network_status'))
@@ -83,11 +115,10 @@ export default function NetworkDashboard() {
return
}
setData(json)
+ saveWorkspaceSnapshot('network_status', json)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setData(null)
- } finally {
- setLoading(false)
}
}, [])
@@ -100,6 +131,8 @@ export default function NetworkDashboard() {
return () => clearInterval(id)
}, [load])
+ const view = data ?? DEFAULT_NETWORK
+
return (
@@ -112,8 +145,8 @@ export default function NetworkDashboard() {
下行 / 256KB 上行)。
- void load()}>
- {loading ? '检测中…' : '重新检测'}
+ void load()}>
+ 重新检测
@@ -123,82 +156,78 @@ export default function NetworkDashboard() {
)}
- {loading && !data && !err && 正在检测网络…
}
+ <>
+
+
+
+
- {data && (
- <>
-
-
-
-
+
+
线路是否像「两条线」
+
+ {view.routes.different === true
+ ? '是(解析国家不同)'
+ : view.routes.different === false
+ ? '否或不确定'
+ : '未知'}
+
+
{view.routes.note}
+
-
-
线路是否像「两条线」
-
- {data.routes.different === true
- ? '是(解析国家不同)'
- : data.routes.different === false
- ? '否或不确定'
- : '未知'}
-
-
{data.routes.note}
-
-
-
-
网速(估算)
-
-
-
海外
-
- 下行
- {fmtMbps(data.speed.overseas.download_mbps)}
-
-
- 上行
- {fmtMbps(data.speed.overseas.upload_mbps)}
-
- {(data.speed.overseas.download_error || data.speed.overseas.upload_error) && (
-
- {[data.speed.overseas.download_error, data.speed.overseas.upload_error]
- .filter(Boolean)
- .join(' · ')}
-
- )}
+
+
网速(估算)
+
+
+
海外
+
+ 下行
+ {fmtMbps(view.speed.overseas.download_mbps)}
-
-
国内
-
- 下行
- {fmtMbps(data.speed.domestic.download_mbps)}
-
-
- 上行
- {fmtMbps(data.speed.domestic.upload_mbps)}
-
- {(data.speed.domestic.download_error || data.speed.domestic.upload_error) && (
-
- {[data.speed.domestic.download_error, data.speed.domestic.upload_error]
- .filter(Boolean)
- .join(' · ')}
-
- )}
+
+ 上行
+ {fmtMbps(view.speed.overseas.upload_mbps)}
+ {(view.speed.overseas.download_error || view.speed.overseas.upload_error) && (
+
+ {[view.speed.overseas.download_error, view.speed.overseas.upload_error]
+ .filter(Boolean)
+ .join(' · ')}
+
+ )}
+
+
+
国内
+
+ 下行
+ {fmtMbps(view.speed.domestic.download_mbps)}
+
+
+ 上行
+ {fmtMbps(view.speed.domestic.upload_mbps)}
+
+ {(view.speed.domestic.download_error || view.speed.domestic.upload_error) && (
+
+ {[view.speed.domestic.download_error, view.speed.domestic.upload_error]
+ .filter(Boolean)
+ .join(' · ')}
+
+ )}
-
- 上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户
- POST。
-
- >
- )}
+
+ 上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户
+ POST。
+
+
+ >
)
}
diff --git a/electron/src/renderer/src/ProxySettings.tsx b/electron/src/renderer/src/ProxySettings.tsx
index c53152b..efbf10d 100644
--- a/electron/src/renderer/src/ProxySettings.tsx
+++ b/electron/src/renderer/src/ProxySettings.tsx
@@ -5,12 +5,10 @@ type ProxyCfg = { enabled: boolean; http: string; socks: string }
export default function ProxySettings() {
const [cfg, setCfg] = useState
({ enabled: false, http: '', socks: '' })
- const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
- setLoading(true)
setMsg('')
try {
const res = await apiFetch(apiUrl('/proxy_config'))
@@ -23,8 +21,6 @@ export default function ProxySettings() {
})
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
- } finally {
- setLoading(false)
}
}, [])
@@ -60,10 +56,7 @@ export default function ProxySettings() {
系统代理。若你使用 Clash Verge 等,请在本机开启「混合端口」后,把地址填成与之一致(常见为 7890)。
YouTube RTMP 推流若仍失败,可能需要代理软件开启 TUN 或自行调整规则。
- {loading ? (
- 正在读取配置…
- ) : (
-
+
- )}
)
}
diff --git a/electron/src/renderer/src/RemoteBinding.tsx b/electron/src/renderer/src/RemoteBinding.tsx
index 9343d6f..ca2f185 100644
--- a/electron/src/renderer/src/RemoteBinding.tsx
+++ b/electron/src/renderer/src/RemoteBinding.tsx
@@ -29,7 +29,6 @@ export default function RemoteBinding() {
: undefined) as RemoteBindingApi | undefined
const [status, setStatus] = useState
(null)
- const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [selectedIp, setSelectedIp] = useState('')
@@ -45,11 +44,9 @@ export default function RemoteBinding() {
const load = useCallback(async () => {
if (!rb?.getStatus) {
- setLoading(false)
setMsg('仅 Electron 桌面端支持此面板;网页调试请直接配置 API。')
return
}
- setLoading(true)
try {
const s = await rb.getStatus()
setStatus(s)
@@ -57,8 +54,6 @@ export default function RemoteBinding() {
} catch (e) {
setStatus(null)
setMsg(e instanceof Error ? e.message : String(e))
- } finally {
- setLoading(false)
}
}, [rb])
@@ -164,15 +159,6 @@ export default function RemoteBinding() {
}
}
- if (loading) {
- return (
-
- )
- }
-
if (!rb?.getStatus) {
return (
@@ -182,19 +168,7 @@ export default function RemoteBinding() {
)
}
- if (!status) {
- return (
-
- 远程控制与扫码绑定
-
- {msg || '无法加载状态'}
-
- void load()}>
- 重试
-
-
- )
- }
+ const s = status
return (
@@ -211,21 +185,21 @@ export default function RemoteBinding() {
-
- {status.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier)'}
+
+ {s?.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier)'}
- {status.easytierBundled && (
-
- {status.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'}
+ {s?.easytierBundled && (
+
+ {s.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'}
)}
- {!!status.easytierNetworkName && (
- 组网名:{status.easytierNetworkName}
+ {!!s?.easytierNetworkName && (
+ 组网名:{s.easytierNetworkName}
)}
- {status.easytierBundled && status.easytierLastError && (
+ {s?.easytierBundled && s.easytierLastError && (
- EasyTier:{status.easytierLastError}
+ EasyTier:{s.easytierLastError}
)}
@@ -305,11 +279,11 @@ export default function RemoteBinding() {
供 React Native 扫码;需已开启「允许远程」并填写可访问的 IP。
- {!status.remoteEnabled && (
+ {!s?.remoteEnabled && (
勾选「允许远程」并生效后,虚拟网内手机可访问本机 API 并扫码绑定。
)}
- {status.remoteEnabled && bindPayload && qrImg && (
+ {s?.remoteEnabled && bindPayload && qrImg && (
React Native 扫码解析 JSON:含 baseUrl、token、
@@ -329,7 +303,7 @@ export default function RemoteBinding() {
)}
- {status.remoteEnabled && !bindPayload && (
+ {s?.remoteEnabled && !bindPayload && (
尚未生成绑定数据:在下拉中选局域网 IP,或在「手动 IP」填写后点「生成绑定二维码」;亦可点「刷新 IP 列表」更新网卡地址。
diff --git a/electron/src/renderer/src/SystemMetrics.tsx b/electron/src/renderer/src/SystemMetrics.tsx
index 79c428d..8b5bdbc 100644
--- a/electron/src/renderer/src/SystemMetrics.tsx
+++ b/electron/src/renderer/src/SystemMetrics.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { apiFetch, apiUrl } from './api'
+import { saveWorkspaceSnapshot } from './workspace'
type Metrics = {
ts?: number
@@ -40,6 +41,7 @@ export default function SystemMetrics() {
}
setErr(null)
setData(json)
+ saveWorkspaceSnapshot('system_metrics', json)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
@@ -68,15 +70,13 @@ export default function SystemMetrics() {
{err}
)}
- {!data && !err && 正在读取…
}
- {data && (
-
+
CPU
- {data.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
+ {data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
-
逻辑处理器 {data.cpu_count_logical ?? '—'} 个
+
逻辑处理器 {data?.cpu_count_logical ?? '—'} 个
内存
@@ -94,7 +94,6 @@ export default function SystemMetrics() {
含所有网卡收发合计
- )}
{data?.swap && data.swap.total != null && data.swap.total > 0 && (
交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
diff --git a/electron/src/renderer/src/WeChatChatbot.tsx b/electron/src/renderer/src/WeChatChatbot.tsx
new file mode 100644
index 0000000..1165624
--- /dev/null
+++ b/electron/src/renderer/src/WeChatChatbot.tsx
@@ -0,0 +1,284 @@
+import { useCallback, useEffect, useState } from 'react'
+import { apiFetch, apiUrl } from './api'
+
+type WechatConfigView = {
+ configured: boolean
+ proxyUrl: string
+ apiKeyMasked: string
+ deviceType: string
+ proxy: string
+ webhookHost: string
+ webhookPort: number
+ webhookPath: string
+ accountId: string
+ session: { wcId?: string; nickName?: string }
+}
+
+export default function WeChatChatbot() {
+ const [cfg, setCfg] = useState
(null)
+ const [msg, setMsg] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const [apiKeyInput, setApiKeyInput] = useState('')
+ const [proxyUrlInput, setProxyUrlInput] = useState('')
+ const [deviceType, setDeviceType] = useState('ipad')
+ const [proxyLine, setProxyLine] = useState('10')
+
+ const [wId, setWId] = useState('')
+ const [qrUrl, setQrUrl] = useState('')
+ const [poll, setPoll] = useState(false)
+ const [proxyStatus, setProxyStatus] = useState | null>(null)
+
+ const load = useCallback(async () => {
+ setMsg('')
+ try {
+ const r = await apiFetch(apiUrl('/chatbot/wechat/config'))
+ if (!r.ok) throw new Error(String(r.status))
+ const j = (await r.json()) as WechatConfigView
+ setCfg(j)
+ setProxyUrlInput(j.proxyUrl || '')
+ setDeviceType(j.deviceType || 'ipad')
+ setProxyLine(j.proxy || '10')
+ setApiKeyInput('')
+ } catch (e) {
+ setCfg(null)
+ setMsg(e instanceof Error ? e.message : String(e))
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ const saveConfig = async () => {
+ setBusy(true)
+ setMsg('')
+ try {
+ const body: Record = {
+ proxyUrl: proxyUrlInput.trim(),
+ deviceType,
+ proxy: proxyLine.trim(),
+ }
+ if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim()
+ const r = await apiFetch(apiUrl('/chatbot/wechat/config'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!r.ok) {
+ const t = await r.text()
+ throw new Error(t || String(r.status))
+ }
+ await load()
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const loadProxyStatus = async () => {
+ setBusy(true)
+ setMsg('')
+ try {
+ const r = await apiFetch(apiUrl('/chatbot/wechat/proxy/status'))
+ const j = await r.json()
+ if (!r.ok) {
+ setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j))
+ setProxyStatus(null)
+ return
+ }
+ setProxyStatus(j as Record)
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const startQr = async () => {
+ setBusy(true)
+ setMsg('')
+ setQrUrl('')
+ setWId('')
+ try {
+ const r = await apiFetch(apiUrl('/chatbot/wechat/qr/start'), { method: 'POST' })
+ const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string }
+ if (!r.ok) {
+ setMsg(j.error || String(r.status))
+ return
+ }
+ if (!j.wId || !j.qrCodeUrl) {
+ setMsg('未返回二维码')
+ return
+ }
+ setWId(j.wId)
+ setQrUrl(j.qrCodeUrl)
+ setPoll(true)
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ useEffect(() => {
+ if (!poll || !wId) return
+ const tick = async () => {
+ try {
+ const r = await apiFetch(apiUrl('/chatbot/wechat/login/check'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ wId }),
+ })
+ const j = (await r.json()) as {
+ status?: string
+ wcId?: string
+ nickName?: string
+ verifyUrl?: string
+ error?: string
+ }
+ if (!r.ok) {
+ setMsg(j.error || String(r.status))
+ setPoll(false)
+ return
+ }
+ if (j.status === 'logged_in') {
+ setPoll(false)
+ setMsg(`已登录:${j.nickName || ''}(${j.wcId || ''})`)
+ void load()
+ return
+ }
+ if (j.status === 'need_verify' && j.verifyUrl) {
+ setMsg(`需要辅助验证:${j.verifyUrl}`)
+ }
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : String(e))
+ setPoll(false)
+ }
+ }
+ const id = window.setInterval(() => void tick(), 5000)
+ void tick()
+ return () => window.clearInterval(id)
+ }, [poll, wId, load])
+
+ return (
+
+
+ 微信 Chatbot
+
+
+ 与 openclaw-wechat 使用同一类代理 API(需自备 apiKey 与 proxyUrl)。保存配置后可在下方获取二维码并用微信扫码登录;亦可用{' '}
+ wechat_chatbot_cli.py 供脚本与 Agent 调用。
+
+
+
+
+ proxyUrl(代理服务根地址)
+
+ setProxyUrlInput(e.target.value)}
+ disabled={busy}
+ autoComplete="off"
+ placeholder="http://your-proxy:3000"
+ />
+
+
+
+ apiKey(留空则不修改已保存密钥)
+
+ setApiKeyInput(e.target.value)}
+ disabled={busy}
+ autoComplete="off"
+ placeholder={cfg?.apiKeyMasked ? `已保存 ${cfg.apiKeyMasked}` : 'wc_live_…'}
+ />
+
+
+
+ deviceType
+
+ setDeviceType(e.target.value)}
+ disabled={busy}
+ >
+ ipad
+ mac
+
+
+ proxy 参数
+
+ setProxyLine(e.target.value)}
+ disabled={busy}
+ />
+
+
+
+ void saveConfig()}>
+ 保存配置
+
+ void load()}>
+ 刷新
+
+ void loadProxyStatus()}>
+ 查询代理状态
+
+
+
+ {proxyStatus && (
+
+ {JSON.stringify(proxyStatus, null, 2)}
+
+ )}
+
+ {cfg?.session?.wcId && (
+
+ 本地记录:{cfg.session.nickName || '—'}({cfg.session.wcId})
+
+ )}
+
+
+ void startQr()}>
+ 获取登录二维码
+
+ {!cfg?.configured && (
+ 请先填写并保存 apiKey 与 proxyUrl(参考 config/wechat_chatbot.example.json)。
+ )}
+
+
+ {qrUrl && (
+
+
+ 请使用微信扫描下方二维码。若图片无法显示,请在新标签页打开二维码链接。
+
+
+
+
+ 打开二维码链接
+
+
+
+ )}
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ )
+}
diff --git a/electron/src/renderer/src/WorkspaceDashboard.tsx b/electron/src/renderer/src/WorkspaceDashboard.tsx
new file mode 100644
index 0000000..e21599b
--- /dev/null
+++ b/electron/src/renderer/src/WorkspaceDashboard.tsx
@@ -0,0 +1,226 @@
+import { useCallback, useEffect, useState } from 'react'
+import type { WorkspaceDbSnapshot } from './workspace'
+import { getWorkspace } from './workspace'
+
+function fmtTs(ts: unknown): string {
+ if (typeof ts !== 'number') return '—'
+ try {
+ return new Date(ts).toLocaleString()
+ } catch {
+ return '—'
+ }
+}
+
+function SnapshotSummary({ data }: { data: unknown }) {
+ if (!data || typeof data !== 'object') return —
+ const d = data as Record
+ if ('google' in d && 'douyin' in d) {
+ const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string }
+ const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string }
+ return (
+
+
+ Google
+ {g?.reachable ? '可达' : '不可达'}
+
+ {g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} ·{' '}
+ {g?.country ?? '—'}
+
+
+
+ 抖音
+ {y?.reachable ? '可达' : '不可达'}
+
+ {y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} ·{' '}
+ {y?.country ?? '—'}
+
+
+
+ )
+ }
+ if ('cpu_percent' in d || 'memory' in d) {
+ const m = d.memory as { percent?: number } | undefined
+ return (
+
+
+ CPU
+
+ {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'}
+
+
+
+ 内存
+ {m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}
+
+
+ )
+ }
+ return (
+ {JSON.stringify(data, null, 2).slice(0, 1200)}
+ )
+}
+
+export default function WorkspaceDashboard() {
+ const ws = getWorkspace()
+ const [data, setData] = useState(null)
+ const [err, setErr] = useState('')
+
+ const load = useCallback(async () => {
+ if (!ws?.getDashboard) {
+ setErr('工作台存储仅在桌面客户端可用。')
+ return
+ }
+ setErr('')
+ try {
+ const d = (await ws.getDashboard()) as WorkspaceDbSnapshot
+ setData(d)
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e))
+ }
+ }, [ws])
+
+ useEffect(() => {
+ void load()
+ const id = window.setInterval(() => void load(), 12000)
+ return () => window.clearInterval(id)
+ }, [load])
+
+ const net = data?.snapshots?.network_status
+ const sys = data?.snapshots?.system_metrics
+
+ return (
+
+
+
+
+ 工作台
+
+
+ 本地 SQLite(用户数据目录 client-data.sqlite)汇总:直播事件、操作记录、客户端日志,以及最近一次网络 / 系统快照。
+
+
+
void load()}>
+ 刷新
+
+
+
+ {err && (
+
+ {err}
+
+ )}
+
+
+
+
网络快照
+ {net ? (
+ <>
+
更新 {fmtTs(net.ts)}
+
+ >
+ ) : (
+
暂无(打开「网络」页后会自动写入)
+ )}
+
+
+
本机负载快照
+ {sys ? (
+ <>
+
更新 {fmtTs(sys.ts)}
+
+ >
+ ) : (
+
暂无(打开「系统」页后会自动写入)
+ )}
+
+
+
+
+
直播与任务事件
+
+
+
+
+ 时间
+ 进程
+ 类型
+ 状态
+ 说明
+
+
+
+ {(data?.liveEvents?.length ? data.liveEvents : []).map((row) => (
+
+ {fmtTs(row.ts)}
+ {String(row.process ?? '—')}
+ {String(row.event_type ?? '—')}
+ {String(row.status ?? '—')}
+
+ {String(row.message ?? '—')}
+
+
+ ))}
+
+
+ {!data?.liveEvents?.length &&
暂无记录
}
+
+
+
+
+
用户操作
+
+
+
+
+ 时间
+ 操作
+ 页面
+ 详情
+
+
+
+ {(data?.userActions?.length ? data.userActions : []).map((row) => (
+
+ {fmtTs(row.ts)}
+ {String(row.action ?? '—')}
+ {String(row.nav ?? '—')}
+
+ {row.detail_json ? String(row.detail_json).slice(0, 120) : '—'}
+
+
+ ))}
+
+
+ {!data?.userActions?.length &&
暂无记录
}
+
+
+
+
+
客户端运行日志
+
+
+
+
+ 时间
+ 级别
+ 来源
+ 内容
+
+
+
+ {(data?.clientLogs?.length ? data.clientLogs : []).map((row) => (
+
+ {fmtTs(row.ts)}
+ {String(row.level ?? '—')}
+ {String(row.source ?? '—')}
+ {String(row.message ?? '—')}
+
+ ))}
+
+
+ {!data?.clientLogs?.length &&
暂无记录
}
+
+
+
+ )
+}
diff --git a/electron/src/renderer/src/vite-env.d.ts b/electron/src/renderer/src/vite-env.d.ts
index 3309233..ea569aa 100644
--- a/electron/src/renderer/src/vite-env.d.ts
+++ b/electron/src/renderer/src/vite-env.d.ts
@@ -34,4 +34,16 @@ interface Window {
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
getBindPayload: (selectedIp: string) => Promise | null>
}
+ workspace?: {
+ logLiveEvent: (payload: unknown) => Promise
+ logUserAction: (payload: unknown) => Promise
+ logClient: (payload: unknown) => Promise
+ saveSnapshot: (key: string, data: unknown) => Promise
+ getDashboard: () => Promise<{
+ liveEvents: Array>
+ userActions: Array>
+ clientLogs: Array>
+ snapshots: Record
+ }>
+ }
}
diff --git a/electron/src/renderer/src/workspace.ts b/electron/src/renderer/src/workspace.ts
new file mode 100644
index 0000000..556c4da
--- /dev/null
+++ b/electron/src/renderer/src/workspace.ts
@@ -0,0 +1,40 @@
+/** 主进程 SQLite 工作台 API(仅 Electron preload 注入时可用) */
+
+export type WorkspaceDbSnapshot = {
+ liveEvents: Array>
+ userActions: Array>
+ clientLogs: Array>
+ snapshots: Record
+}
+
+export function getWorkspace() {
+ return typeof window !== 'undefined'
+ ? (window as Window & { workspace?: WorkspaceApi }).workspace
+ : undefined
+}
+
+type WorkspaceApi = {
+ logLiveEvent: (payload: unknown) => Promise
+ logUserAction: (payload: unknown) => Promise
+ logClient: (payload: unknown) => Promise
+ saveSnapshot: (key: string, data: unknown) => Promise
+ getDashboard: () => Promise
+}
+
+export function saveWorkspaceSnapshot(key: string, data: unknown): void {
+ void getWorkspace()?.saveSnapshot(key, data)
+}
+
+export function logUserAction(action: string, nav?: string, detail?: unknown): void {
+ void getWorkspace()?.logUserAction({ action, nav, detail })
+}
+
+export function logLiveEvent(payload: {
+ eventType: string
+ process?: string
+ status?: string
+ message?: string
+ detail?: unknown
+}): void {
+ void getWorkspace()?.logLiveEvent(payload)
+}
diff --git a/integrations/openclaw-wechat/README.md b/integrations/openclaw-wechat/README.md
new file mode 100644
index 0000000..f395c38
--- /dev/null
+++ b/integrations/openclaw-wechat/README.md
@@ -0,0 +1,20 @@
+# openclaw-wechat(上游 OpenClaw 插件)
+
+本目录用于**引用**开源仓库 [freestylefly/openclaw-wechat](https://github.com/freestylefly/openclaw-wechat):在已安装 [OpenClaw](https://github.com/openclaw/openclaw) 的环境下,作为 **OpenClaw 通道插件**使用微信代理 API,与网关 `openclaw gateway start` 配合(终端扫码登录等)。
+
+与本仓库 **web2 内置能力**的关系:
+
+- **Python 桥接**(`web2_wechat_chatbot.py`)直接调用与插件相同的**代理服务 HTTP API**(`/v1/account/status`、`/v1/iPadLogin` 等),无需运行 OpenClaw 即可在「直播录制助手」里配置密钥、拉取二维码、轮询登录。
+- 若你已在 OpenClaw 中使用该插件,只需将 **同一套 `apiKey` + `proxyUrl`** 写入 `config/wechat_chatbot.json`,两端行为一致。
+
+## 添加子模块(可选)
+
+```bash
+git submodule add https://github.com/freestylefly/openclaw-wechat.git integrations/openclaw-wechat/repo
+```
+
+随后可在 OpenClaw 侧按上游 README 安装:`openclaw plugins install @canghe/openclaw-wechat`(以 [上游说明](https://github.com/freestylefly/openclaw-wechat) 为准)。
+
+## 许可
+
+上游为 MIT;使用微信代理服务须遵守其条款,且勿用于违法用途(见上游仓库声明)。
diff --git a/skills/wechat-chatbot/SKILL.md b/skills/wechat-chatbot/SKILL.md
new file mode 100644
index 0000000..f172b2f
--- /dev/null
+++ b/skills/wechat-chatbot/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: wechat-chatbot
+description: 通过本仓库 web2 的 /chatbot/wechat API 配置微信代理 Chatbot、扫码登录与发消息(与 openclaw-wechat 代理协议一致)。
+---
+
+# 微信 Chatbot(HTTP API)
+
+后端基址默认 `http://127.0.0.1:8001`(或 `WEB2_API_BASE`)。若启用 `WEB2_API_TOKEN`,请求需带 `Authorization: Bearer `。
+
+## 配置
+
+- 复制 `config/wechat_chatbot.example.json` 为 `config/wechat_chatbot.json`,填写 `apiKey`、`proxyUrl`(由代理服务商提供,与 [openclaw-wechat](https://github.com/freestylefly/openclaw-wechat) 一致)。
+- 或通过 API 写入:`POST /chatbot/wechat/config`,JSON 字段见示例文件。
+
+## 常用端点
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/chatbot/wechat/config` | 查看配置(apiKey 脱敏)与本地会话摘要 |
+| POST | `/chatbot/wechat/config` | 保存配置 |
+| GET | `/chatbot/wechat/proxy/status` | 代理账号状态 |
+| POST | `/chatbot/wechat/qr/start` | 获取登录二维码 URL 与 `wId` |
+| POST | `/chatbot/wechat/login/check` | Body:`{"wId":"..."}`,轮询直至 `logged_in` |
+| POST | `/chatbot/wechat/send` | Body:`{"targetId":"wxid或群id","text":"..."}` |
+| POST | `/chatbot/wechat/webhook/register` | Body:`{"webhookUrl":"https://..."}` 供云端收消息 |
+
+## CLI
+
+```bash
+python wechat_chatbot_cli.py config-get --json
+python wechat_chatbot_cli.py qr-start --json
+python wechat_chatbot_cli.py login-check --w-id --json
+python wechat_chatbot_cli.py send --target --text "hi" --json
+```
+
+## 与 CLI-Anything / Agent 的协作方式
+
+- 优先使用 **`--json`** 或解析 HTTP JSON,便于与 [CLI-Anything](https://github.com/HKUDS/CLI-Anything) 类工具链衔接。
+- 扫码流程:先 `qr/start` 展示 `qrCodeUrl`,每约 5 秒调用 `login/check` 直至成功或超时。
diff --git a/web2.py b/web2.py
index 82464c9..c48fa78 100644
--- a/web2.py
+++ b/web2.py
@@ -25,6 +25,7 @@ from web2_youtube_oauth import (
oauth_exchange_code,
oauth_status as youtube_oauth_status_snapshot,
)
+from web2_wechat_chatbot import register_wechat_chatbot_routes
app = FastAPI(title="多进程录制控制台")
@@ -184,6 +185,7 @@ app.add_middleware(
allow_headers=["*"],
)
+register_wechat_chatbot_routes(app, BASE_DIR)
# ---------------- 工具函数 ----------------
async def read_log_path(path: str, lines: int) -> str:
diff --git a/web2_wechat_chatbot.py b/web2_wechat_chatbot.py
new file mode 100644
index 0000000..a602dc4
--- /dev/null
+++ b/web2_wechat_chatbot.py
@@ -0,0 +1,292 @@
+"""
+微信 Chatbot 桥接:与 openclaw-wechat 使用的代理 HTTP API 对齐(见 integrations/openclaw-wechat)。
+需自备 apiKey 与 proxyUrl(第三方代理服务),本模块提供扫码登录、状态查询与发消息等 REST,供 Web / CLI / Agent 调用。
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Optional
+
+import httpx
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+
+CONFIG_NAME = "wechat_chatbot.json"
+STATE_NAME = "wechat_chatbot.state.json"
+
+
+def _config_path(base: Path) -> Path:
+ return base / "config" / CONFIG_NAME
+
+
+def _state_path(base: Path) -> Path:
+ return base / "config" / STATE_NAME
+
+
+def load_wechat_chatbot_config(base_dir: Path) -> dict[str, Any]:
+ p = _config_path(base_dir)
+ if not p.exists():
+ return {}
+ try:
+ return json.loads(p.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def load_wechat_state(base_dir: Path) -> dict[str, Any]:
+ p = _state_path(base_dir)
+ if not p.exists():
+ return {}
+ try:
+ return json.loads(p.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def save_wechat_state(base_dir: Path, state: dict[str, Any]) -> None:
+ p = _state_path(base_dir)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def mask_api_key(key: str) -> str:
+ k = (key or "").strip()
+ if len(k) <= 8:
+ return "****" if k else ""
+ return f"{k[:4]}…{k[-4:]}"
+
+
+class WechatProxyClient:
+ """与 freestylefly/openclaw-wechat 中 ProxyClient 请求格式一致。"""
+
+ def __init__(
+ self,
+ api_key: str,
+ base_url: str,
+ account_id: str = "default",
+ timeout: float = 60.0,
+ ):
+ if not api_key or not api_key.strip():
+ raise ValueError("缺少 apiKey")
+ if not base_url or not base_url.strip():
+ raise ValueError("缺少 proxyUrl")
+ self.api_key = api_key.strip()
+ self.base_url = base_url.strip().rstrip("/")
+ self.account_id = account_id.strip() or "default"
+ self.timeout = timeout
+
+ def _request(self, endpoint: str, data: Optional[dict[str, Any]] = None) -> Any:
+ url = f"{self.base_url}{endpoint}"
+ headers = {
+ "Content-Type": "application/json",
+ "X-API-Key": self.api_key,
+ "X-Account-ID": self.account_id,
+ }
+ with httpx.Client(timeout=self.timeout) as client:
+ r = client.post(url, headers=headers, json=data)
+ try:
+ result = r.json()
+ except Exception:
+ raise RuntimeError(f"HTTP {r.status_code}: {r.text[:500]}") from None
+
+ if not r.is_success:
+ raise RuntimeError(result.get("error") or result.get("message") or f"HTTP {r.status_code}")
+
+ code = result.get("code")
+ if code in ("1000", "1001", "1002"):
+ d = result.get("data")
+ return d if isinstance(d, (dict, list)) else result
+ if code and str(code) != "1000":
+ raise RuntimeError(result.get("message") or str(result))
+ d = result.get("data")
+ return d if d is not None else result
+
+ def get_status(self) -> dict[str, Any]:
+ out = self._request("/v1/account/status")
+ if not isinstance(out, dict):
+ return {"raw": out}
+ return {
+ "valid": out.get("valid", True),
+ "wcId": out.get("wcId"),
+ "isLoggedIn": out.get("isLoggedIn", False),
+ "nickName": out.get("nickName"),
+ "tier": out.get("tier"),
+ "quota": out.get("quota"),
+ "error": out.get("error"),
+ }
+
+ def get_qr_code(self, device_type: str = "ipad", proxy: str = "10") -> dict[str, str]:
+ out = self._request(
+ "/v1/iPadLogin",
+ {"deviceType": device_type or "ipad", "proxy": proxy or "10"},
+ )
+ if not isinstance(out, dict):
+ raise RuntimeError("代理返回格式异常")
+ w_id = out.get("wId") or out.get("w_id")
+ qr_url = out.get("qrCodeUrl") or out.get("qr_code_url")
+ if not w_id or not qr_url:
+ raise RuntimeError("代理未返回二维码信息")
+ return {"wId": str(w_id), "qrCodeUrl": str(qr_url)}
+
+ def check_login(self, w_id: str) -> dict[str, Any]:
+ out = self._request("/v1/getIPadLoginInfo", {"wId": w_id})
+ if not isinstance(out, dict):
+ return {"status": "waiting"}
+ st = out.get("status")
+ if st == "logged_in":
+ return {
+ "status": "logged_in",
+ "wcId": out.get("wcId"),
+ "nickName": out.get("nickName"),
+ "headUrl": out.get("headUrl"),
+ }
+ if st == "need_verify":
+ return {"status": "need_verify", "verifyUrl": out.get("verifyUrl")}
+ return {"status": "waiting"}
+
+ def send_text(self, target_id: str, content: str) -> dict[str, Any]:
+ out = self._request("/v1/sendText", {"wcId": target_id, "content": content})
+ return out if isinstance(out, dict) else {"result": out}
+
+ def register_webhook(self, webhook_url: str) -> None:
+ self._request("/v1/webhook/register", {"webhookUrl": webhook_url})
+
+
+def build_client(cfg: dict[str, Any]) -> WechatProxyClient:
+ return WechatProxyClient(
+ api_key=str(cfg.get("apiKey") or ""),
+ base_url=str(cfg.get("proxyUrl") or ""),
+ account_id=str(cfg.get("accountId") or "default"),
+ )
+
+
+class WechatConfigBody(BaseModel):
+ apiKey: Optional[str] = None
+ proxyUrl: Optional[str] = None
+ deviceType: Optional[str] = None
+ proxy: Optional[str] = None
+ webhookHost: Optional[str] = None
+ webhookPort: Optional[int] = None
+ webhookPath: Optional[str] = None
+ accountId: Optional[str] = None
+
+
+class WechatSendBody(BaseModel):
+ targetId: str = Field(..., description="接收方 wxid 或群 id")
+ text: str = Field(..., description="文本内容")
+
+
+class WechatWebhookBody(BaseModel):
+ webhookUrl: str
+
+
+def register_wechat_chatbot_routes(app, base_dir: Path) -> None:
+ """在 FastAPI app 上注册 /chatbot/wechat/* 路由。"""
+
+ @app.get("/chatbot/wechat/config")
+ async def wechat_config_get():
+ cfg = load_wechat_chatbot_config(base_dir)
+ st = load_wechat_state(base_dir)
+ key = str(cfg.get("apiKey") or "")
+ return {
+ "configured": bool(cfg.get("proxyUrl") and key),
+ "proxyUrl": cfg.get("proxyUrl") or "",
+ "apiKeyMasked": mask_api_key(key),
+ "deviceType": cfg.get("deviceType") or "ipad",
+ "proxy": cfg.get("proxy") or "10",
+ "webhookHost": cfg.get("webhookHost") or "",
+ "webhookPort": int(cfg.get("webhookPort") or 18790),
+ "webhookPath": cfg.get("webhookPath") or "/webhook/wechat",
+ "accountId": cfg.get("accountId") or "default",
+ "session": {
+ "wcId": st.get("wcId"),
+ "nickName": st.get("nickName"),
+ },
+ }
+
+ @app.post("/chatbot/wechat/config")
+ async def wechat_config_post(body: WechatConfigBody):
+ cfg = load_wechat_chatbot_config(base_dir)
+ d = body.model_dump(exclude_none=True)
+ for k, v in d.items():
+ cfg[k] = v
+ out_path = _config_path(base_dir)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
+ return {"ok": True}
+
+ @app.get("/chatbot/wechat/proxy/status")
+ async def wechat_proxy_status():
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ return c.get_status()
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/qr/start")
+ async def wechat_qr_start():
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ dt = str(cfg.get("deviceType") or "ipad")
+ px = str(cfg.get("proxy") or "10")
+ return c.get_qr_code(device_type=dt, proxy=px)
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/login/check")
+ async def wechat_login_check(request: Request):
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "需要 JSON body: { \"wId\": \"...\" }"}, status_code=400)
+ w_id = (body or {}).get("wId") or (body or {}).get("w_id")
+ if not w_id or not isinstance(w_id, str):
+ return JSONResponse({"error": "缺少 wId"}, status_code=400)
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ r = c.check_login(w_id.strip())
+ if r.get("status") == "logged_in" and r.get("wcId"):
+ save_wechat_state(
+ base_dir,
+ {
+ "wcId": r.get("wcId"),
+ "nickName": r.get("nickName"),
+ },
+ )
+ return r
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/send")
+ async def wechat_send(body: WechatSendBody):
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ return c.send_text(body.targetId.strip(), body.text)
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/webhook/register")
+ async def wechat_webhook_reg(body: WechatWebhookBody):
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ c.register_webhook(body.webhookUrl.strip())
+ return {"ok": True}
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
diff --git a/wechat_chatbot_cli.py b/wechat_chatbot_cli.py
new file mode 100644
index 0000000..3181aea
--- /dev/null
+++ b/wechat_chatbot_cli.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""
+微信 Chatbot HTTP API 的命令行客户端(供脚本与 Agent 调用)。
+环境变量:WEB2_API_BASE(默认 http://127.0.0.1:8001)、WEB2_API_TOKEN(若后端启用鉴权)。
+
+示例:
+ python wechat_chatbot_cli.py config-get --json
+ python wechat_chatbot_cli.py proxy-status --json
+ python wechat_chatbot_cli.py qr-start --json
+ python wechat_chatbot_cli.py login-check --w-id --json
+ python wechat_chatbot_cli.py send --target wxid_xxx --text "hello" --json
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+
+import httpx
+
+
+def base_url() -> str:
+ return os.environ.get("WEB2_API_BASE", "http://127.0.0.1:8001").rstrip("/")
+
+
+def headers() -> dict[str, str]:
+ h = {"Content-Type": "application/json"}
+ tok = os.environ.get("WEB2_API_TOKEN", "").strip()
+ if tok:
+ h["Authorization"] = f"Bearer {tok}"
+ return h
+
+
+def out(obj: object, as_json: bool) -> None:
+ if as_json:
+ print(json.dumps(obj, ensure_ascii=False, indent=2))
+ else:
+ print(obj)
+
+
+def main() -> None:
+ p = argparse.ArgumentParser(description="微信 Chatbot API CLI")
+ p.add_argument("--json", action="store_true", help="输出 JSON")
+ sub = p.add_subparsers(dest="cmd", required=True)
+
+ sub.add_parser("config-get", help="GET /chatbot/wechat/config")
+
+ pc = sub.add_parser("config-set", help="POST /chatbot/wechat/config(从 stdin 读 JSON 或文件)")
+ pc.add_argument("--file", "-f", help="JSON 文件路径")
+
+ sub.add_parser("proxy-status", help="GET /chatbot/wechat/proxy/status")
+
+ sub.add_parser("qr-start", help="POST /chatbot/wechat/qr/start")
+
+ pl = sub.add_parser("login-check", help="POST /chatbot/wechat/login/check")
+ pl.add_argument("--w-id", required=True, dest="w_id")
+
+ ps = sub.add_parser("send", help="POST /chatbot/wechat/send")
+ ps.add_argument("--target", required=True, help="对方 wxid 或群 id")
+ ps.add_argument("--text", required=True)
+
+ pw = sub.add_parser("webhook-register", help="POST /chatbot/wechat/webhook/register")
+ pw.add_argument("--url", required=True, help="公网可访问的 webhook 完整 URL")
+
+ args = p.parse_args()
+ bu = base_url()
+ h = headers()
+
+ try:
+ with httpx.Client(timeout=120.0) as client:
+ if args.cmd == "config-get":
+ r = client.get(f"{bu}/chatbot/wechat/config", headers=h)
+ r.raise_for_status()
+ out(r.json(), args.json)
+ elif args.cmd == "config-set":
+ raw = sys.stdin.read()
+ if getattr(args, "file", None):
+ raw = open(args.file, encoding="utf-8").read()
+ body = json.loads(raw)
+ r = client.post(f"{bu}/chatbot/wechat/config", headers=h, json=body)
+ r.raise_for_status()
+ out(r.json(), args.json)
+ elif args.cmd == "proxy-status":
+ r = client.get(f"{bu}/chatbot/wechat/proxy/status", headers=h)
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "qr-start":
+ r = client.post(f"{bu}/chatbot/wechat/qr/start", headers=h)
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "login-check":
+ r = client.post(
+ f"{bu}/chatbot/wechat/login/check",
+ headers=h,
+ json={"wId": args.w_id},
+ )
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "send":
+ r = client.post(
+ f"{bu}/chatbot/wechat/send",
+ headers=h,
+ json={"targetId": args.target, "text": args.text},
+ )
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "webhook-register":
+ r = client.post(
+ f"{bu}/chatbot/wechat/webhook/register",
+ headers=h,
+ json={"webhookUrl": args.url},
+ )
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ except httpx.HTTPStatusError as e:
+ print(str(e), file=sys.stderr)
+ try:
+ print(e.response.text, file=sys.stderr)
+ except Exception:
+ pass
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()