mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
Fix yuanbao platform test real IM delivery
This commit is contained in:
@@ -2,10 +2,15 @@ import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import { pathToFileURL } from "url";
|
||||
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
|
||||
const QQBOT_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||
const QQBOT_API_BASE = "https://api.sgroup.qq.com";
|
||||
const YUANBAO_PLUGIN_DIST_DIR = path.join(OPENCLAW_HOME, "extensions/openclaw-plugin-yuanbao/dist/src");
|
||||
const DEFAULT_YUANBAO_API_DOMAIN = "bot.yuanbao.tencent.com";
|
||||
const DEFAULT_YUANBAO_WS_URL = "wss://bot-wss.yuanbao.tencent.com/wss/connection";
|
||||
const importExternalModule = new Function("modulePath", "return import(modulePath)") as (modulePath: string) => Promise<any>;
|
||||
|
||||
interface PlatformTestResult {
|
||||
agentId: string;
|
||||
@@ -17,6 +22,11 @@ interface PlatformTestResult {
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
interface YuanbaoDmContext {
|
||||
target: string;
|
||||
accountId: string | null;
|
||||
}
|
||||
|
||||
function runOpenClawMessageSend(channel: string, target: string, message: string, extraArgs: string[] = []): string {
|
||||
const args = [
|
||||
"message", "send",
|
||||
@@ -34,6 +44,24 @@ function runOpenClawMessageSend(channel: string, target: string, message: string
|
||||
});
|
||||
}
|
||||
|
||||
async function probeGatewayWebUi(port: number, token: string, timeoutMs = 5000): Promise<{ ok: boolean; error?: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`http://localhost:${port}/chat${token ? `?token=${encodeURIComponent(token)}` : ""}`,
|
||||
{ signal: controller.signal, cache: "no-store", redirect: "manual" },
|
||||
);
|
||||
return resp.status >= 200 && resp.status < 400
|
||||
? { ok: true }
|
||||
: { ok: false, error: `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err?.message || "Failed to reach gateway web UI" };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function runCurlJson(url: string, options: { method?: string; headers?: string[]; body?: string; timeoutSec?: number } = {}): { status: number; data: any; raw: string } {
|
||||
const args = [
|
||||
'-sS',
|
||||
@@ -319,6 +347,155 @@ function getDiscordAllowlistUser(discordConfig: any): string | null {
|
||||
return first ? first.trim() : null;
|
||||
}
|
||||
|
||||
function getChannelDmUser(agentId: string, channel: string): string | null {
|
||||
try {
|
||||
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions/sessions.json`);
|
||||
const raw = fs.readFileSync(sessionsPath, "utf-8");
|
||||
const sessions = JSON.parse(raw);
|
||||
let bestId: string | null = null;
|
||||
let bestTime = 0;
|
||||
const pattern = new RegExp(`^agent:[^:]+:${channel}:direct:(.+)$`);
|
||||
for (const [key, val] of Object.entries(sessions)) {
|
||||
const m = key.match(pattern);
|
||||
if (m) {
|
||||
const updatedAt = (val as any).updatedAt || 0;
|
||||
if (updatedAt > bestTime) {
|
||||
bestTime = updatedAt;
|
||||
bestId = m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripChannelTarget(value: string | null | undefined, channel: string): string | null {
|
||||
if (!value || typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const prefix = `${channel}:`;
|
||||
return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed;
|
||||
}
|
||||
|
||||
function getYuanbaoDmContext(agentId: string): YuanbaoDmContext | null {
|
||||
try {
|
||||
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions/sessions.json`);
|
||||
const raw = fs.readFileSync(sessionsPath, "utf-8");
|
||||
const sessions = JSON.parse(raw);
|
||||
let best: YuanbaoDmContext | null = null;
|
||||
let bestTime = 0;
|
||||
|
||||
for (const [key, val] of Object.entries(sessions)) {
|
||||
const match = key.match(/^agent:[^:]+:yuanbao:direct:(.+)$/);
|
||||
if (!match) continue;
|
||||
const session = val as any;
|
||||
const updatedAt = session?.updatedAt || 0;
|
||||
if (updatedAt <= bestTime) continue;
|
||||
|
||||
const target = stripChannelTarget(session?.deliveryContext?.to, "yuanbao")
|
||||
|| stripChannelTarget(session?.origin?.to, "yuanbao")
|
||||
|| match[1];
|
||||
if (!target) continue;
|
||||
|
||||
bestTime = updatedAt;
|
||||
best = {
|
||||
target,
|
||||
accountId: typeof session?.deliveryContext?.accountId === "string" && session.deliveryContext.accountId.trim()
|
||||
? session.deliveryContext.accountId.trim()
|
||||
: (typeof session?.origin?.accountId === "string" && session.origin.accountId.trim()
|
||||
? session.origin.accountId.trim()
|
||||
: null),
|
||||
};
|
||||
}
|
||||
|
||||
return best;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveYuanbaoTestAccount(channelConfig: any, preferredAccountId?: string | null) {
|
||||
const accounts = channelConfig?.accounts && typeof channelConfig.accounts === "object"
|
||||
? channelConfig.accounts
|
||||
: {};
|
||||
const availableAccountIds = Object.keys(accounts).filter((value) => value.trim().length > 0);
|
||||
const defaultAccountId = typeof channelConfig?.defaultAccount === "string" && channelConfig.defaultAccount.trim()
|
||||
? channelConfig.defaultAccount.trim()
|
||||
: (availableAccountIds.includes("default") ? "default" : (availableAccountIds[0] ?? "default"));
|
||||
const accountId = preferredAccountId?.trim() || defaultAccountId;
|
||||
const scopedConfig = accounts?.[accountId] && typeof accounts[accountId] === "object"
|
||||
? accounts[accountId]
|
||||
: {};
|
||||
const merged = { ...channelConfig, ...scopedConfig };
|
||||
const { accounts: _accounts, defaultAccount: _defaultAccount, ...config } = merged;
|
||||
|
||||
const appKey = typeof config.appKey === "string" ? config.appKey.trim() : "";
|
||||
const appSecret = typeof config.appSecret === "string" ? config.appSecret.trim() : "";
|
||||
const identifier = typeof config.identifier === "string" ? config.identifier.trim() : "";
|
||||
const token = typeof config.token === "string" ? config.token.trim() : "";
|
||||
const apiDomain = typeof config.apiDomain === "string" && config.apiDomain.trim()
|
||||
? config.apiDomain.trim()
|
||||
: DEFAULT_YUANBAO_API_DOMAIN;
|
||||
const wsGatewayUrl = typeof config.wsUrl === "string" && config.wsUrl.trim()
|
||||
? config.wsUrl.trim()
|
||||
: DEFAULT_YUANBAO_WS_URL;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
account: {
|
||||
accountId,
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(appKey && appSecret),
|
||||
appKey: appKey || undefined,
|
||||
appSecret: appSecret || undefined,
|
||||
identifier: identifier || undefined,
|
||||
botId: typeof config.botId === "string" && config.botId.trim() ? config.botId.trim() : undefined,
|
||||
apiDomain,
|
||||
token: token || undefined,
|
||||
wsGatewayUrl,
|
||||
wsHeartbeatInterval: undefined,
|
||||
wsMaxReconnectAttempts: 1,
|
||||
overflowPolicy: config.overflowPolicy === "split" ? "split" : "stop",
|
||||
mediaMaxMb: typeof config.mediaMaxMb === "number" && config.mediaMaxMb >= 1 ? config.mediaMaxMb : 20,
|
||||
historyLimit: typeof config.historyLimit === "number" && config.historyLimit >= 0 ? config.historyLimit : 100,
|
||||
config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let yuanbaoRuntimePromise: Promise<{
|
||||
getSignToken: (account: any, log?: any) => Promise<any>;
|
||||
YuanbaoWsClient: any;
|
||||
sendYuanbaoMessage: (params: any) => Promise<any>;
|
||||
}> | null = null;
|
||||
|
||||
async function loadYuanbaoRuntime() {
|
||||
if (!yuanbaoRuntimePromise) {
|
||||
yuanbaoRuntimePromise = Promise.all([
|
||||
importExternalModule(pathToFileURL(path.join(YUANBAO_PLUGIN_DIST_DIR, "yuanbao-server/http/request.js")).href),
|
||||
importExternalModule(pathToFileURL(path.join(YUANBAO_PLUGIN_DIST_DIR, "yuanbao-server/ws/client.js")).href),
|
||||
importExternalModule(pathToFileURL(path.join(YUANBAO_PLUGIN_DIST_DIR, "message-handler/outbound.js")).href),
|
||||
]).then(([requestModule, clientModule, outboundModule]) => ({
|
||||
getSignToken: requestModule.getSignToken,
|
||||
YuanbaoWsClient: clientModule.YuanbaoWsClient,
|
||||
sendYuanbaoMessage: outboundModule.sendYuanbaoMessage,
|
||||
}));
|
||||
}
|
||||
return yuanbaoRuntimePromise;
|
||||
}
|
||||
|
||||
function getChannelAllowlistUser(channelConfig: any): string | null {
|
||||
const list = Array.isArray(channelConfig?.allowFrom)
|
||||
? channelConfig.allowFrom
|
||||
: Array.isArray(channelConfig?.dm?.allowFrom)
|
||||
? channelConfig.dm.allowFrom
|
||||
: [];
|
||||
const first = list.find((v: any) => typeof v === "string" && v.trim().length > 0);
|
||||
return first ? first.trim() : null;
|
||||
}
|
||||
|
||||
// Find the most recent telegram DM chat_id for a given agent
|
||||
function getTelegramDmUser(agentId: string): string | null {
|
||||
try {
|
||||
@@ -382,6 +559,199 @@ async function testTelegram(
|
||||
}
|
||||
}
|
||||
|
||||
async function testYuanbao(
|
||||
agentId: string,
|
||||
channelConfig: any,
|
||||
testUserId: string | null,
|
||||
recipientSource: "session" | "allowFrom" | "none",
|
||||
preferredAccountId?: string | null,
|
||||
): Promise<PlatformTestResult> {
|
||||
const startTime = Date.now();
|
||||
const { accountId, account } = resolveYuanbaoTestAccount(channelConfig, preferredAccountId);
|
||||
|
||||
if (!account.appKey || !account.appSecret) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: "Yuanbao credentials missing. Configure channels.yuanbao.appKey and channels.yuanbao.appSecret",
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (!testUserId) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: "No Yuanbao recipient configured. Set channels.yuanbao.allowFrom or start one DM session first",
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
let wsClient: any = null;
|
||||
|
||||
try {
|
||||
const { getSignToken, YuanbaoWsClient, sendYuanbaoMessage } = await loadYuanbaoRuntime();
|
||||
const tokenData = await getSignToken(account);
|
||||
const botId = typeof tokenData?.bot_id === "string" && tokenData.bot_id.trim()
|
||||
? tokenData.bot_id.trim()
|
||||
: (typeof account.botId === "string" && account.botId.trim()
|
||||
? account.botId.trim()
|
||||
: (typeof account.identifier === "string" ? account.identifier : ""));
|
||||
|
||||
if (!botId) {
|
||||
throw new Error("Yuanbao sign token succeeded but bot_id is missing");
|
||||
}
|
||||
|
||||
account.botId = botId;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
wsClient?.disconnect?.();
|
||||
reject(new Error("Yuanbao WebSocket ready timeout"));
|
||||
}, 20000);
|
||||
|
||||
const finish = (cb: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
cb();
|
||||
};
|
||||
|
||||
wsClient = new YuanbaoWsClient({
|
||||
connection: {
|
||||
gatewayUrl: account.wsGatewayUrl,
|
||||
auth: {
|
||||
bizId: "ybBot",
|
||||
uid: botId,
|
||||
source: tokenData?.source || "bot",
|
||||
token: tokenData?.token,
|
||||
...(account.config?.routeEnv ? { routeEnv: account.config.routeEnv } : {}),
|
||||
},
|
||||
},
|
||||
config: {
|
||||
maxReconnectAttempts: account.wsMaxReconnectAttempts,
|
||||
},
|
||||
callbacks: {
|
||||
onReady: () => finish(resolve),
|
||||
onError: (error: Error) => finish(() => reject(error)),
|
||||
onClose: (code: number, reason: string) => finish(() => reject(new Error(`Yuanbao WebSocket closed before ready: ${code} ${reason || ""}`.trim()))),
|
||||
},
|
||||
log: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
wsClient.connect();
|
||||
});
|
||||
|
||||
const now = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
const sendResult = await sendYuanbaoMessage({
|
||||
account,
|
||||
toAccount: testUserId,
|
||||
text: `[Platform Test] Yuanbao 联通测试 ✅ (${now})`,
|
||||
fromAccount: account.botId,
|
||||
ctx: {
|
||||
account,
|
||||
config: {},
|
||||
core: {},
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} },
|
||||
wsClient,
|
||||
},
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (!sendResult?.ok) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: sendResult?.error || "Yuanbao real IM send failed",
|
||||
elapsed,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceLabel = recipientSource === "allowFrom" ? "allowFrom" : "session";
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: true,
|
||||
detail: `Yuanbao → real IM sent to ${testUserId} (${elapsed}ms, via ${sourceLabel})${sendResult?.messageId ? ` · msgId=${sendResult.messageId}` : ""}`,
|
||||
elapsed,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: err?.message || "Yuanbao real IM send failed",
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
} finally {
|
||||
wsClient?.disconnect?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function testGenericChannel(
|
||||
agentId: string,
|
||||
channel: string,
|
||||
testUserId: string | null,
|
||||
recipientSource: "session" | "allowFrom" | "none"
|
||||
): Promise<PlatformTestResult> {
|
||||
const startTime = Date.now();
|
||||
const displayName = channel.charAt(0).toUpperCase() + channel.slice(1);
|
||||
|
||||
if (!testUserId) {
|
||||
return {
|
||||
agentId,
|
||||
platform: channel,
|
||||
ok: false,
|
||||
error: `No ${displayName} recipient configured. Set channels.${channel}.allowFrom or start one DM session first`,
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
const result = runOpenClawMessageSend(
|
||||
channel,
|
||||
testUserId,
|
||||
`[Platform Test] ${displayName} 联通测试 ✅ (${now})`,
|
||||
["--silent"]
|
||||
);
|
||||
const elapsed = Date.now() - startTime;
|
||||
const sourceLabel = recipientSource === "allowFrom" ? "allowFrom" : "session";
|
||||
const outputSummary = result.trim().slice(0, 120);
|
||||
return {
|
||||
agentId,
|
||||
platform: channel,
|
||||
ok: true,
|
||||
detail: `${displayName} → DM sent to ${testUserId} (${elapsed}ms, via ${sourceLabel})${outputSummary ? ` · ${outputSummary}` : ""}`,
|
||||
elapsed,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
agentId,
|
||||
platform: channel,
|
||||
ok: false,
|
||||
error: (err.stderr || err.message || "Unknown error").slice(0, 300),
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Find the most recent whatsapp DM user for a given agent
|
||||
function getWhatsappDmUser(agentId: string): string | null {
|
||||
try {
|
||||
@@ -718,6 +1088,7 @@ export async function POST() {
|
||||
const telegramConfig = channels.telegram || {};
|
||||
const whatsappConfig = channels.whatsapp || {};
|
||||
const qqbotConfig = channels.qqbot;
|
||||
const specialPlatformNames = new Set(["feishu", "discord", "telegram", "whatsapp", "qqbot"]);
|
||||
|
||||
// Read gateway config early (needed for WhatsApp test)
|
||||
const gatewayPort = config.gateway?.port || 18789;
|
||||
@@ -810,6 +1181,32 @@ export async function POST() {
|
||||
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
|
||||
sequentialPlatformTests.push(() => testQqbot(id, qqbotConfig, qqbotAccountId, qqbotTestUser, source));
|
||||
}
|
||||
|
||||
for (const [channelName, channelConfig] of Object.entries(channels)) {
|
||||
if (specialPlatformNames.has(channelName)) continue;
|
||||
if (!channelConfig || typeof channelConfig !== "object" || (channelConfig as any).enabled === false) continue;
|
||||
|
||||
const hasBinding = bindings.some(
|
||||
(b: any) => b.agentId === id && b.match?.channel === channelName
|
||||
);
|
||||
if (id !== "main" && !hasBinding) continue;
|
||||
|
||||
const yuanbaoDmContext = channelName === "yuanbao" ? getYuanbaoDmContext(id) : null;
|
||||
const recentDmUser = channelName === "yuanbao"
|
||||
? (yuanbaoDmContext?.target ?? null)
|
||||
: getChannelDmUser(id, channelName);
|
||||
const allowFromUser = channelName === "yuanbao"
|
||||
? stripChannelTarget(getChannelAllowlistUser(channelConfig), "yuanbao")
|
||||
: getChannelAllowlistUser(channelConfig);
|
||||
const testUserId = recentDmUser || allowFromUser || null;
|
||||
const source: "session" | "allowFrom" | "none" =
|
||||
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
|
||||
if (channelName === "yuanbao") {
|
||||
sequentialPlatformTests.push(() => testYuanbao(id, channelConfig, testUserId, source, yuanbaoDmContext?.accountId ?? null));
|
||||
} else {
|
||||
sequentialPlatformTests.push(() => testGenericChannel(id, channelName, testUserId, source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const platformResults = await Promise.all(platformTests);
|
||||
|
||||
Reference in New Issue
Block a user