fix(chat,channels): admin session delete, weixin inbound, PG and docs

- List/delete chat sessions for administrator by username (cross-tenant UUID)

- Return 404 when delete does not remove a session; add tests

- Weixin: dispatch lang, inbound attachments, reply persist, native reply timeout

- PG compat scrub and administrator session delete repo path

- RUNBOOK: WhatsApp re-bind with Remove-Item auth; weixin poll diag scripts

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-03 01:36:45 +08:00
parent 39fd1905b6
commit bc1b45a8fd
20 changed files with 1056 additions and 86 deletions

View file

@ -15,7 +15,7 @@ $sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
$stateDir = Join-Path $sidecarRoot "state"
function Sync-WeixinBridgeRunners {
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\weixin_bridge"
foreach ($name in @("official_runner.ts", "login.ts")) {
foreach ($name in @("official_runner.ts", "login.ts", "poll_diag.ts")) {
$srcPath = Join-Path $bridgeSrc $name
if (-not (Test-Path $srcPath)) {
throw "missing bridge source file: $srcPath"

View file

@ -0,0 +1,28 @@
param(
[switch]$ResetCursor = $false
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
$sidecarRoot = Join-Path $repoRoot "data\channel_sidecar\oclaw-weixin"
$stateDir = Join-Path $sidecarRoot "state"
$bridgeSrc = Join-Path $repoRoot "runtime\operations\weixin_bridge\poll_diag.ts"
if (-not (Test-Path $bridgeSrc)) {
throw "missing $bridgeSrc"
}
Copy-Item -Path $bridgeSrc -Destination (Join-Path $sidecarRoot "poll_diag.ts") -Force
$args = @("exec", "--", "tsx", "poll_diag.ts")
if ($ResetCursor) {
$args += "--reset-cursor"
}
Push-Location $sidecarRoot
try {
$env:OCLAW_STATE_DIR = $stateDir
$env:OPENCLAW_STATE_DIR = $stateDir
npm.cmd @args
} finally {
Pop-Location
}

View file

@ -84,7 +84,7 @@ $cleaned = Stop-SidecarProcesses
Remove-Item -Force $pidFile -ErrorAction SilentlyContinue
if (Test-Path $bridgeSrc) {
foreach ($name in @("official_runner.ts", "login.ts")) {
foreach ($name in @("official_runner.ts", "login.ts", "poll_diag.ts")) {
$srcPath = Join-Path $bridgeSrc $name
if (Test-Path $srcPath) {
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
@ -99,7 +99,7 @@ if (Test-Path (Join-Path $sidecarRoot "official_runner.ts")) {
$cmd = "cmd.exe"
$args = @(
"/c",
"cd /d $sidecarRoot && set OCLAW_STATE_DIR=$stateDir&& set AIA_GATEWAY_BASE_URL=$GatewayBaseUrl&& set NODE_PATH=$sidecarRoot\node_modules&& npm.cmd exec -- tsx official_runner.ts"
"cd /d $sidecarRoot && set OCLAW_STATE_DIR=$stateDir&& set OPENCLAW_STATE_DIR=$stateDir&& set AIA_GATEWAY_BASE_URL=$GatewayBaseUrl&& set OCLAW_WEIXIN_LOG_FILE=$logPath&& set NODE_PATH=$sidecarRoot\node_modules&& npm.cmd exec -- tsx official_runner.ts"
)
$p = Start-Process -FilePath $cmd -ArgumentList $args -WorkingDirectory $sidecarRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath
Set-Content -Path $pidFile -Value $p.Id

View file

@ -8,6 +8,7 @@ type TokenMap = Record<string, string>;
const LOCAL_BASE_URL = (process.env.AIA_GATEWAY_BASE_URL || "http://127.0.0.1:8787").trim();
const STATE_DIR = (process.env.OCLAW_STATE_DIR || path.resolve(process.cwd(), "state")).trim();
const STATE_FILE = path.join(STATE_DIR, "official_bridge_state.json");
const LOG_FILE = String(process.env.OCLAW_WEIXIN_LOG_FILE || "").trim();
const POLL_TIMEOUT_MS = 35_000;
const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
@ -65,7 +66,21 @@ class HttpStatusError extends Error {
}
function log(msg: string): void {
process.stdout.write(`${new Date().toISOString()} [official-weixin] ${msg}\n`);
const line = `${new Date().toISOString()} [official-weixin] ${msg}\n`;
// When started via Start-Process -RedirectStandardOutput, Node may block-buffer stdout;
// append directly so operators see poll/inbound lines in weixin_sidecar.log immediately.
if (LOG_FILE) {
try {
fs.appendFileSync(LOG_FILE, line, "utf8");
} catch {
// ignore
}
}
try {
process.stdout.write(line);
} catch {
// ignore
}
}
function ensureDir(dir: string): void {
@ -88,6 +103,18 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
function resolvePluginRoot(): string {
const configured = String(process.env.OCLAW_WEIXIN_PLUGIN_ROOT || "").trim();
return configured || path.join(process.cwd(), "node_modules", "@tencent-weixin", "openclaw-weixin");
@ -167,10 +194,12 @@ function resolveHeaders(token: string): Record<string, string> {
async function postNativeReply(token: string, body: Json): Promise<Json> {
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/weixin/native/reply`;
const timeoutMs = Number(process.env.OCLAW_WEIXIN_NATIVE_REPLY_TIMEOUT_MS || "100000") || 100000;
const res = await fetch(url, {
method: "POST",
headers: resolveHeaders(token),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await res.text();
if (!res.ok) {
@ -330,9 +359,19 @@ async function handleInboundMessage(
): Promise<string> {
const fromUser = String(params.full.from_user_id || "").trim();
const toUser = String(params.full.to_user_id || "").trim();
if (!fromUser) return params.localCursor;
if (toUser && toUser === fromUser) return params.localCursor;
if (Number(params.full.message_type || 1) !== 1) return params.localCursor;
if (!fromUser) {
log(`skip inbound: missing from_user_id`);
return params.localCursor;
}
if (toUser && toUser === fromUser) {
log(`skip inbound: self-message from=${fromUser}`);
return params.localCursor;
}
if (Number(params.full.message_type || 1) !== 1) {
log(`skip inbound: message_type=${String(params.full.message_type ?? "")} from=${fromUser}`);
return params.localCursor;
}
log(`inbound from=${fromUser} to=${toUser || "-"}`);
const contextToken = String(params.full.context_token || "").trim();
if (contextToken) {
@ -341,17 +380,33 @@ async function handleInboundMessage(
}
const mediaItem = pickDownloadableMedia(params.full);
const mediaOpts = mediaItem
? await modules.downloadMediaFromItem(mediaItem, {
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
saveMedia: saveMediaBuffer,
log: (msg: string) => log(`media ${msg}`),
errLog: (msg: string) => log(`media-error ${msg}`),
label: "inbound",
})
: {};
let mediaOpts: Json = {};
if (mediaItem) {
log("media download start");
try {
mediaOpts = await withTimeout(
modules.downloadMediaFromItem(mediaItem, {
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
saveMedia: saveMediaBuffer,
log: (msg: string) => log(`media ${msg}`),
errLog: (msg: string) => log(`media-error ${msg}`),
label: "inbound",
}),
Number(process.env.OCLAW_WEIXIN_MEDIA_TIMEOUT_MS || "90000") || 90000,
"media download",
);
log("media download done");
} catch (err) {
log(`media download failed err=${String(err)}`);
mediaOpts = {};
}
}
const ctx = modules.weixinMessageToMsgContext(params.full, params.accountId, mediaOpts);
const bodyText = String((ctx as Json).Body || "").trim();
const attachCount = buildAttachmentsFromMedia(mediaOpts).length;
log(`native call bodyLen=${bodyText.length} attachments=${attachCount}`);
let replies: Json[] = [];
const tNative0 = Date.now();
try {
const native = await postNativeReply(params.token, {
channel: "wechat",
@ -366,6 +421,11 @@ async function handleInboundMessage(
},
});
replies = Array.isArray(native.replies) ? (native.replies as Json[]) : [];
const firstText = replies.length ? String((replies[0] as Json).text || "").trim() : "";
log(`native done ms=${Date.now() - tNative0} replies=${replies.length} firstTextLen=${firstText.length}`);
if (!replies.length) {
log("native returned 0 replies (empty inbound, gateway suppress, or LLM produced no text)");
}
} catch (err) {
log(`native reply failed; no fallback enabled err=${String(err)}`);
await safeSendNativeFailureNotice(modules, {
@ -447,6 +507,13 @@ async function main(): Promise<void> {
timeoutMs: POLL_TIMEOUT_MS + 5000,
});
const msgs = Array.isArray(out.msgs) ? (out.msgs as Json[]) : [];
const ret = Number(out.ret ?? 0);
const errcode = out.errcode;
if (ret !== 0 || errcode != null) {
log(`poll ret=${ret} errcode=${String(errcode ?? "")} errmsg=${String(out.errmsg ?? "")}`);
} else if (msgs.length > 0) {
log(`poll batch=${msgs.length}`);
}
const nextCloudCursor = String(out.get_updates_buf || cloudCursor || "").trim();
if (nextCloudCursor) {
cloudCursor = nextCloudCursor;

View file

@ -0,0 +1,109 @@
/**
* One-shot ilink getUpdates probe (diagnose "sidecar started but no messages").
* Run from repo: runtime/operations/scripts/weixin_poll_diag.ps1
*/
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const STATE_DIR = (process.env.OCLAW_STATE_DIR || path.resolve(process.cwd(), "state")).trim();
const STATE_FILE = path.join(STATE_DIR, "official_bridge_state.json");
function resolvePluginRoot(): string {
const configured = String(process.env.OCLAW_WEIXIN_PLUGIN_ROOT || "").trim();
return configured || path.join(process.cwd(), "node_modules", "@tencent-weixin", "openclaw-weixin");
}
async function resolveAccount(): Promise<{ accountId: string; token: string; cloudBaseUrl: string }> {
if (!String(process.env.OPENCLAW_STATE_DIR || "").trim()) {
process.env.OPENCLAW_STATE_DIR = STATE_DIR;
}
const pluginRoot = resolvePluginRoot();
const srcRoot = path.join(pluginRoot, "src");
const importTs = async (relativePath: string) => import(pathToFileURL(path.join(srcRoot, relativePath)).href);
const accountsMod = await importTs(path.join("auth", "accounts.ts"));
const listIds = accountsMod.listIndexedWeixinAccountIds as () => string[];
const loadAcc = accountsMod.loadWeixinAccount as (id: string) => Record<string, unknown>;
const accountId = String((listIds() || [])[0] || "").trim();
if (!accountId) throw new Error("no weixin account; run weixin_login.ps1");
const data = loadAcc(accountId) || {};
const token = String(data.token || "").trim();
if (!token) throw new Error(`missing token for ${accountId}; run weixin_login.ps1`);
const envCloud = String(process.env.OCLAW_WEIXIN_CLOUD_BASE_URL || "").trim();
const cfgCloud = String(data.baseUrl || "").trim();
const cloudBaseUrl = (envCloud || cfgCloud || "https://ilinkai.weixin.qq.com").trim();
return { accountId, token, cloudBaseUrl };
}
async function main(): Promise<void> {
const reset = process.argv.includes("--reset-cursor");
const stateRaw = (() => {
try {
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")) as Record<string, unknown>;
} catch {
return {};
}
})();
let cursor = reset ? "" : String(stateRaw.cloud_cursor || "").trim();
const { accountId, token, cloudBaseUrl } = await resolveAccount();
const pluginRoot = resolvePluginRoot();
const apiMod = await import(pathToFileURL(path.join(pluginRoot, "src", "api", "api.ts")).href);
const getUpdates = apiMod.getUpdates as (p: Record<string, unknown>) => Promise<Record<string, unknown>>;
console.log(
JSON.stringify(
{
accountId,
cloudBaseUrl,
cursorPrefix: cursor.slice(0, 48),
resetCursor: reset,
},
null,
2,
),
);
const out = await getUpdates({
baseUrl: cloudBaseUrl,
token,
get_updates_buf: cursor,
timeoutMs: 15_000,
});
const msgs = Array.isArray(out.msgs) ? out.msgs : [];
console.log(
JSON.stringify(
{
ret: out.ret,
errcode: out.errcode,
errmsg: out.errmsg,
msgCount: msgs.length,
nextCursorPrefix: String(out.get_updates_buf || "").slice(0, 48),
},
null,
2,
),
);
if (msgs.length > 0) {
const m = msgs[0] as Record<string, unknown>;
console.log(
"first_msg",
JSON.stringify(
{
from_user_id: m.from_user_id,
to_user_id: m.to_user_id,
message_type: m.message_type,
has_text: Boolean(m.text || (m as { content?: unknown }).content),
},
null,
2,
),
);
} else {
console.log("hint=send a NEW text message to the bot now, then re-run this script within 30s");
}
}
void main().catch((err) => {
console.error("poll_diag_failed", err);
process.exit(1);
});