feat: 初始化环信 SDK 测试项目
- 添加项目配置文档 (AGENTS.md) - 创建 package.json,包含环信 SDK 及测试依赖 - 添加环信 polyfill 工具模块 - 添加双用户消息互发测试脚本 - 添加 polyfill 测试脚本 Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
206
AGENTS.md
Normal file
206
AGENTS.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
```bash
|
||||
# Development - starts Vite dev server (port 5175) + Electron app with hot reload
|
||||
npm run electron:dev
|
||||
|
||||
# Build production bundle (TypeScript + Vite)
|
||||
npm run build
|
||||
|
||||
# Lint with ESLint
|
||||
npm run lint
|
||||
|
||||
# Run memory extractor tests (Node.js built-in test runner)
|
||||
npm run test:memory
|
||||
|
||||
# Compile Electron main process only
|
||||
npm run compile:electron
|
||||
|
||||
# Package for distribution (platform-specific)
|
||||
npm run dist:mac # macOS (.dmg)
|
||||
npm run dist:win # Windows (.exe)
|
||||
npm run dist:linux # Linux (.AppImage)
|
||||
```
|
||||
|
||||
**Requirements**: Node.js >=24 <25. Windows builds require PortableGit (see README.md for setup).
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
OriginClawAI is an Electron + React desktop application with two primary modes:
|
||||
1. **Cowork Mode** - AI-assisted coding sessions using Claude Agent SDK with tool execution
|
||||
2. **Artifacts System** - Rich preview of code outputs (HTML, SVG, React, Mermaid)
|
||||
|
||||
Uses strict process isolation with IPC communication.
|
||||
|
||||
### Process Model
|
||||
|
||||
**Main Process** (`src/main/main.ts`):
|
||||
- Window lifecycle management
|
||||
- SQLite storage via `sql.js` (`src/main/sqliteStore.ts`)
|
||||
- Cowork session runner (`src/main/libs/coworkRunner.ts`) - executes Claude Agent SDK
|
||||
- IPC handlers for store, cowork, and API operations
|
||||
- Security: context isolation enabled, node integration disabled, sandbox enabled
|
||||
|
||||
**Preload Script** (`src/main/preload.ts`):
|
||||
- Exposes `window.electron` API via `contextBridge`
|
||||
- Includes `cowork` namespace for session management and streaming events
|
||||
|
||||
**Renderer Process** (React in `src/renderer/`):
|
||||
- All UI and business logic
|
||||
- Communicates with main process exclusively through IPC
|
||||
|
||||
### Key Directories
|
||||
|
||||
```
|
||||
src/main/
|
||||
├── main.ts # Entry point, IPC handlers
|
||||
├── sqliteStore.ts # SQLite database (kv + cowork tables)
|
||||
├── coworkStore.ts # Cowork session/message CRUD operations
|
||||
└── libs/
|
||||
├── coworkRunner.ts # Claude Agent SDK execution engine
|
||||
├── coworkVmRunner.ts # Sandbox VM execution mode
|
||||
├── claudeSdk.ts # SDK loader utilities
|
||||
├── coworkMemoryExtractor.ts # Extracts memory changes from conversations
|
||||
└── coworkMemoryJudge.ts # Validates memory candidates with scoring/LLM
|
||||
|
||||
src/renderer/
|
||||
├── types/cowork.ts # Cowork type definitions
|
||||
├── store/slices/
|
||||
│ ├── coworkSlice.ts # Cowork sessions and streaming state
|
||||
│ └── artifactSlice.ts # Artifacts state
|
||||
├── services/
|
||||
│ ├── cowork.ts # Cowork service (IPC wrapper, Redux integration)
|
||||
│ ├── api.ts # LLM API with SSE streaming
|
||||
│ └── artifactParser.ts # Artifact detection and parsing
|
||||
├── components/
|
||||
│ ├── cowork/ # Cowork UI components
|
||||
│ │ ├── CoworkView.tsx # Main cowork interface
|
||||
│ │ ├── CoworkSessionList.tsx # Session sidebar
|
||||
│ │ ├── CoworkSessionDetail.tsx # Message display
|
||||
│ │ └── CoworkPermissionModal.tsx # Tool permission UI
|
||||
│ └── artifacts/ # Artifact renderers
|
||||
|
||||
SKILLs/ # Custom skill definitions for cowork sessions
|
||||
├── skills.config.json # Skill enable/order configuration
|
||||
├── docx/ # Word document generation skill
|
||||
├── xlsx/ # Excel skill
|
||||
├── pptx/ # PowerPoint skill
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Initialization**: `src/renderer/App.tsx` → `coworkService.init()` → loads config/sessions via IPC → sets up stream listeners
|
||||
2. **Cowork Session**: User sends prompt → `coworkService.startSession()` → IPC to main → `CoworkRunner.startSession()` → Claude Agent SDK execution → streaming events back to renderer via IPC → Redux updates
|
||||
3. **Tool Permissions**: Claude requests tool use → `CoworkRunner` emits `permissionRequest` → UI shows `CoworkPermissionModal` → user approves/denies → result sent back to SDK
|
||||
4. **Persistence**: Cowork sessions stored in SQLite (`cowork_sessions`, `cowork_messages` tables)
|
||||
|
||||
### Cowork System
|
||||
|
||||
The Cowork feature provides AI-assisted coding sessions:
|
||||
|
||||
**Execution Modes** (`CoworkExecutionMode`):
|
||||
- `auto` - Automatically choose based on context
|
||||
- `local` - Run tools directly on the local machine
|
||||
- `sandbox` - Run tools in isolated VM environment
|
||||
|
||||
**Memory System**: Automatically extracts and manages user memories from conversations:
|
||||
- `coworkMemoryExtractor.ts` - Detects explicit remember/forget commands (Chinese/English) and implicitly extracts personal facts using signal patterns (profile, preferences, ownership). Uses guard levels (`strict`/`standard`/`relaxed`) with confidence thresholds.
|
||||
- `coworkMemoryJudge.ts` - Validates memory candidates with rule-based scoring and optional LLM secondary judgment for borderline cases. Includes TTL-based caching for LLM results.
|
||||
|
||||
**Stream Events** (IPC from main to renderer):
|
||||
- `message` - New message added to session
|
||||
- `messageUpdate` - Streaming content update for existing message
|
||||
- `permissionRequest` - Tool needs user approval
|
||||
- `complete` - Session execution finished
|
||||
- `error` - Session encountered an error
|
||||
|
||||
**Key IPC Channels**:
|
||||
- `cowork:startSession`, `cowork:continueSession`, `cowork:stopSession`
|
||||
- `cowork:getSession`, `cowork:listSessions`, `cowork:deleteSession`
|
||||
- `cowork:respondToPermission`, `cowork:getConfig`, `cowork:setConfig`
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Streaming responses**: `apiService.chat()` uses SSE with `onProgress` callback for real-time message updates
|
||||
- **Cowork streaming**: Uses IPC event listeners (`onStreamMessage`, `onStreamMessageUpdate`, etc.) for bidirectional communication
|
||||
- **Markdown rendering**: `react-markdown` with `remark-gfm`, `remark-math`, `rehype-katex` for GitHub markdown and LaTeX
|
||||
- **Theme system**: Class-based Tailwind dark mode, applies `dark` class to `<html>` element
|
||||
- **i18n**: Simple key-value translation in `services/i18n.ts`, supports Chinese (default) and English. Language auto-detected from system locale on first run.
|
||||
- **Path alias**: `@` maps to `src/renderer/` in Vite config for imports.
|
||||
- **Skills**: Custom skill definitions in `SKILLs/` directory, configured via `skills.config.json`
|
||||
|
||||
### Artifacts System
|
||||
|
||||
The Artifacts feature provides rich preview of code outputs similar to Claude's artifacts:
|
||||
|
||||
**Supported Types**:
|
||||
- `html` - Full HTML pages rendered in sandboxed iframe
|
||||
- `svg` - SVG graphics with DOMPurify sanitization and zoom controls
|
||||
- `mermaid` - Flowcharts, sequence diagrams, class diagrams via Mermaid.js
|
||||
- `react` - React/JSX components compiled with Babel in isolated iframe
|
||||
- `code` - Syntax highlighted code with line numbers
|
||||
|
||||
**Detection Methods**:
|
||||
1. Explicit markers: ` ```artifact:html title="My Page" `
|
||||
2. Heuristic detection: Analyzes code block language and content patterns
|
||||
|
||||
**UI Components**:
|
||||
- Right-side panel (300-800px resizable width)
|
||||
- Header with type icon, title, copy/download/close buttons
|
||||
- Artifact badges in messages to switch between artifacts
|
||||
|
||||
**Security**:
|
||||
- HTML: `sandbox="allow-scripts"` with no `allow-same-origin`
|
||||
- SVG: DOMPurify removes all script content
|
||||
- React: Completely isolated iframe with no network access
|
||||
- Mermaid: `securityLevel: 'strict'` configuration
|
||||
|
||||
### Configuration
|
||||
|
||||
- App config stored in SQLite `kv` table
|
||||
- Cowork config stored in `cowork_config` table (workingDirectory, systemPrompt, executionMode)
|
||||
- Cowork sessions and messages stored in `cowork_sessions` and `cowork_messages` tables
|
||||
- Database file: `originclawai.sqlite` in user data directory
|
||||
|
||||
### TypeScript Configuration
|
||||
|
||||
- `tsconfig.json`: React/renderer code (ES2020, ESNext modules)
|
||||
- `electron-tsconfig.json`: Electron main process (CommonJS output to `dist-electron/`)
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
- `@anthropic-ai/claude-agent-sdk` - Claude Agent SDK for cowork sessions
|
||||
- `sql.js` - SQLite database for persistence
|
||||
- `react-markdown`, `remark-gfm`, `rehype-katex` - Markdown rendering with math support
|
||||
- `mermaid` - Diagram rendering
|
||||
- `dompurify` - SVG/HTML sanitization
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Use TypeScript, functional React components, and Hooks; keep logic in `src/renderer/services/` when it is not UI-specific.
|
||||
- Match existing formatting: 2-space indentation, single quotes, and semicolons.
|
||||
- Naming: `PascalCase` for components (e.g., `Chat.tsx`), `camelCase` for functions/vars, and `*Slice.ts` for Redux slices.
|
||||
- Tailwind CSS is the primary styling approach; prefer utility classes over bespoke CSS.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Tests use Node.js built-in `node:test` module (no Jest/Mocha/Vitest).
|
||||
- Run tests: `npm run test:memory` (compiles Electron main process first, then runs `tests/coworkMemoryExtractor.test.mjs`).
|
||||
- Test files live in `tests/` directory and import compiled output from `dist-electron/`.
|
||||
- Validate UI changes manually by running `npm run electron:dev` and exercising key flows:
|
||||
- Cowork: start session, send prompts, approve/deny tool permissions, stop session
|
||||
- Artifacts: preview HTML, SVG, Mermaid diagrams, React components
|
||||
- Settings: theme switching, language switching
|
||||
- Keep console warnings/errors clean; lint via `npm run lint` before submitting.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Recent history uses conventional prefixes like `feat:`, `refactor:`, and `chore:`; older commits include `feature:` and `Initial commit`.
|
||||
- Prefer `type: short imperative summary` (e.g., `feat: add artifact toolbar actions`).
|
||||
- PRs should include a concise description, linked issue if applicable, and screenshots for UI changes.
|
||||
- Call out any Electron-specific behavior changes (IPC, storage, windowing) in the PR description.
|
||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "easemob-sdk-test",
|
||||
"version": "1.0.0",
|
||||
"description": "环信 SDK 测试项目",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test:msg": "node scripts/test-easemob-msg.js",
|
||||
"test:polyfill": "node scripts/test-easemob-polyfill.js xhr2 \"1101260329209923#demo\" \"originclaw_bot\" \"OriginClaw2026!\""
|
||||
},
|
||||
"dependencies": {
|
||||
"easemob-websdk": "^4.19.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"ws": "^8.18.0",
|
||||
"xhr2": "^0.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.8.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
137
scripts/easemob-polyfill.js
Normal file
137
scripts/easemob-polyfill.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 环信 SDK Node.js Polyfill 工具模块
|
||||
*
|
||||
* 提供 easemob-websdk 在 Node.js 下运行所需的浏览器 API 模拟。
|
||||
*
|
||||
* 用法:
|
||||
* const { applyManualPolyfill, applyHappyDomPolyfill, printGlobalState } = require('./easemob-polyfill');
|
||||
* applyManualPolyfill();
|
||||
*/
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 手动 Polyfill:逐项注入缺失的浏览器 API
|
||||
// ──────────────────────────────────────────
|
||||
function applyManualPolyfill() {
|
||||
// window → globalThis(关键!让 typeof window === 'object',SDK 据此判断浏览器环境)
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
globalThis.window = globalThis;
|
||||
console.log(' + window = globalThis');
|
||||
}
|
||||
|
||||
// self → globalThis
|
||||
if (typeof globalThis.self === 'undefined') {
|
||||
globalThis.self = globalThis;
|
||||
console.log(' + self = globalThis');
|
||||
}
|
||||
|
||||
// WebSocket → ws
|
||||
if (typeof globalThis.WebSocket === 'undefined') {
|
||||
try {
|
||||
const wsModule = require('ws');
|
||||
globalThis.WebSocket = wsModule.WebSocket || wsModule;
|
||||
console.log(' + WebSocket = ws (Node.js)');
|
||||
} catch {
|
||||
console.warn(' ! ws 包未安装,跳过 WebSocket polyfill');
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage → 内存 Map
|
||||
if (typeof globalThis.localStorage === 'undefined') {
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => store.get(key) ?? null,
|
||||
setItem: (key, value) => { store.set(key, String(value)); },
|
||||
removeItem: (key) => { store.delete(key); },
|
||||
clear: () => { store.clear(); },
|
||||
get length() { return store.size; },
|
||||
key: (_index) => null,
|
||||
};
|
||||
console.log(' + localStorage = in-memory Map');
|
||||
}
|
||||
|
||||
// XMLHttpRequest 由调用方按需注入(xhr2 / xmlhttprequest),此处不再自动注入
|
||||
|
||||
// navigator 基本模拟
|
||||
if (typeof globalThis.navigator === 'undefined') {
|
||||
globalThis.navigator = {
|
||||
userAgent: 'Node.js',
|
||||
platform: process.platform,
|
||||
appName: 'Node.js',
|
||||
appVersion: process.version,
|
||||
language: 'zh-CN',
|
||||
};
|
||||
console.log(' + navigator = basic mock');
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// happy-dom Polyfill:注入完整浏览器环境
|
||||
// ──────────────────────────────────────────
|
||||
async function applyHappyDomPolyfill() {
|
||||
let happyDOM;
|
||||
try {
|
||||
happyDOM = require('happy-dom');
|
||||
} catch {
|
||||
console.error(' ✗ happy-dom 未安装');
|
||||
console.error(' 请运行: npm install happy-dom --save-dev');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// happy-dom v20+ uses Browser + BrowserContext API
|
||||
if (happyDOM.Browser && happyDOM.BrowserContext) {
|
||||
const browser = new happyDOM.Browser();
|
||||
const page = browser.newPage('https://a1.easemob.com');
|
||||
const window = page.mainFrame.window;
|
||||
const document = window.document;
|
||||
|
||||
globalThis.window = window;
|
||||
globalThis.document = document;
|
||||
globalThis.self = window;
|
||||
globalThis.navigator = window.navigator;
|
||||
globalThis.localStorage = window.localStorage;
|
||||
globalThis.XMLHttpRequest = window.XMLHttpRequest;
|
||||
globalThis.WebSocket = window.WebSocket;
|
||||
globalThis.fetch = window.fetch.bind(window);
|
||||
globalThis.Headers = window.Headers;
|
||||
globalThis.Request = window.Request;
|
||||
globalThis.Response = window.Response;
|
||||
|
||||
console.log(' + happy-dom Browser + BrowserContext ✓');
|
||||
console.log(` + page URL: ${window.location.href}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 旧版 GlobalRegistrator API (happy-dom < v15)
|
||||
const Registrator = happyDOM.GlobalRegistrator;
|
||||
if (Registrator && Registrator.register) {
|
||||
await Registrator.register();
|
||||
console.log(' + happy-dom GlobalRegistrator.register() ✓');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(' ✗ happy-dom 版本不兼容,无法注入全局 API');
|
||||
console.error(' 当前版本:', require('happy-dom/package.json').version);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 打印当前全局 API 状态
|
||||
// ──────────────────────────────────────────
|
||||
function printGlobalState() {
|
||||
const apis = [
|
||||
'self', 'WebSocket', 'localStorage', 'XMLHttpRequest',
|
||||
'navigator', 'fetch', 'window', 'document',
|
||||
];
|
||||
console.log('\n 全局 API 状态:');
|
||||
for (const api of apis) {
|
||||
const exists = typeof globalThis[api] !== 'undefined';
|
||||
const type = typeof globalThis[api];
|
||||
console.log(` ${exists ? '✓' : '✗'} ${api}: ${exists ? type : 'undefined'}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyManualPolyfill,
|
||||
applyHappyDomPolyfill,
|
||||
printGlobalState,
|
||||
};
|
||||
312
scripts/test-easemob-msg.js
Normal file
312
scripts/test-easemob-msg.js
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 环信 SDK 双用户消息互发测试脚本
|
||||
*
|
||||
* 同时登录两个环信账号(originclaw_bot 和 testuser1),
|
||||
* 测试双向消息收发:
|
||||
* 1. 双方各自连接 + 登录
|
||||
* 2. testuser1 -> originclaw_bot 发送消息
|
||||
* 3. originclaw_bot 收到后自动回复
|
||||
* 4. originclaw_bot -> testuser1 主动发消息
|
||||
* 5. testuser1 收到后自动回复
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/test-easemob-msg.js # 默认互发测试
|
||||
* node scripts/test-easemob-msg.js <自定义消息> # 指定初始消息内容
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 测试账号配置
|
||||
// ──────────────────────────────────────────
|
||||
const ACCOUNTS = {
|
||||
bot: {
|
||||
label: 'originclaw_bot',
|
||||
user: 'originclaw_bot',
|
||||
pwd: 'OriginClaw2026!',
|
||||
},
|
||||
tester: {
|
||||
label: 'testuser1',
|
||||
user: 'testuser1',
|
||||
pwd: 'Test123456',
|
||||
},
|
||||
};
|
||||
const APP_KEY = '1101260329209923#demo';
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Step 0: 从 SQLite 读取配置(获取服务器地址等)
|
||||
// ──────────────────────────────────────────
|
||||
async function loadConfigFromDB() {
|
||||
const initSqlJs = require('sql.js');
|
||||
const dbPath = path.join(process.env.APPDATA, 'OriginClawAI', 'originclawai.sqlite');
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.warn(`⚠ 数据库文件不存在: ${dbPath},使用默认配置`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs();
|
||||
const buf = fs.readFileSync(dbPath);
|
||||
const db = new SQL.Database(buf);
|
||||
|
||||
const result = db.exec("SELECT value FROM im_config WHERE key = 'easemob'");
|
||||
db.close();
|
||||
|
||||
if (!result[0]?.values?.[0]?.[0]) {
|
||||
console.warn('⚠ 数据库中没有环信配置,使用默认配置');
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].values[0][0]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// Polyfill
|
||||
// ──────────────────────────────────────────
|
||||
const { applyManualPolyfill } = require('./easemob-polyfill');
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 为账号创建连接实例并登录
|
||||
// ──────────────────────────────────────────
|
||||
function createConnection(account, websdk, config) {
|
||||
const conn = new websdk.connection({
|
||||
appKey: APP_KEY,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
const peer = account.label === 'bot' ? ACCOUNTS.tester : ACCOUNTS.bot;
|
||||
|
||||
// 消息统计(per-account)
|
||||
const stats = { sent: 0, received: 0 };
|
||||
|
||||
conn.listen({
|
||||
onOpened: () => {
|
||||
console.log(`\n [${account.label}] ✅ 登录成功!`);
|
||||
},
|
||||
|
||||
onClosed: () => {
|
||||
console.log(` [${account.label}] 连接已关闭`);
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
const errMsg = error?.message || error?.data?.error_description || JSON.stringify(error);
|
||||
console.error(` [${account.label}] ❌ Error: ${errMsg}`);
|
||||
},
|
||||
|
||||
onTextMessage: (message) => {
|
||||
stats.received++;
|
||||
const from = message.from || 'unknown';
|
||||
const data = message.data || '';
|
||||
|
||||
console.log(`\n [${account.label}] 📩 收到消息 from=${from}: "${data}"`);
|
||||
|
||||
// 自动 echo 回复
|
||||
const replyText = `[${account.label} Echo] 收到: "${data.substring(0, 30)}"`;
|
||||
sendTextMessage(conn, websdk, from, replyText, account.label, stats);
|
||||
},
|
||||
|
||||
onPresence: (message) => {
|
||||
console.log(` [${account.label}] Presence: ${message?.type} from ${message?.from || ''}`);
|
||||
},
|
||||
});
|
||||
|
||||
return { conn, stats, account, peer };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 发送文本消息
|
||||
// ──────────────────────────────────────────
|
||||
function sendTextMessage(conn, websdk, to, text, label, stats) {
|
||||
try {
|
||||
const message = new websdk.message({
|
||||
to,
|
||||
type: 'chat',
|
||||
msg: text,
|
||||
chatType: 'singleChat',
|
||||
});
|
||||
const result = conn.send(message);
|
||||
// conn.send 可能返回 Promise
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch((err) => {
|
||||
console.error(` [${label}] ❌ 发送失败(async): ${err?.message || err}`);
|
||||
});
|
||||
}
|
||||
stats.sent++;
|
||||
console.log(` [${label}] 📤 已发送 -> ${to}: "${text}"`);
|
||||
} catch (err) {
|
||||
console.error(` [${label}] ❌ 发送失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 延时工具
|
||||
// ──────────────────────────────────────────
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 主流程
|
||||
// ──────────────────────────────────────────
|
||||
async function main() {
|
||||
const customMsg = process.argv.slice(2).join(' ') || '你好,这是双向测试消息!';
|
||||
|
||||
console.log('=== 环信双向消息收发测试 ===');
|
||||
console.log(` Bot 账号: ${ACCOUNTS.bot.user}`);
|
||||
console.log(` 测试账号: ${ACCOUNTS.tester.user}`);
|
||||
console.log(` 初始消息: "${customMsg}"`);
|
||||
console.log('================================\n');
|
||||
|
||||
// Step 1: 读取数据库配置
|
||||
console.log('[Step 1] 读取配置...');
|
||||
let config;
|
||||
try {
|
||||
config = await loadConfigFromDB();
|
||||
if (config) {
|
||||
console.log(` AppKey: ${config.appKey}`);
|
||||
console.log(` REST Server: ${config.restServer || '(默认)'}`);
|
||||
} else {
|
||||
console.log(' 使用硬编码默认配置');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(' ⚠ 读取配置失败,使用默认:', err.message);
|
||||
}
|
||||
|
||||
// Step 2: Polyfill
|
||||
console.log('\n[Step 2] 应用 Polyfill...');
|
||||
applyManualPolyfill();
|
||||
|
||||
// WebSocket polyfill
|
||||
try {
|
||||
const wsModule = require('ws');
|
||||
const OriginalWebSocket = wsModule.WebSocket || wsModule;
|
||||
globalThis.WebSocket = function DebugWebSocket(url, protocols, options) {
|
||||
console.log(` [WS] new WebSocket(${JSON.stringify(url)})`);
|
||||
if (protocols !== undefined) {
|
||||
return new OriginalWebSocket(url, protocols, options);
|
||||
}
|
||||
return new OriginalWebSocket(url);
|
||||
};
|
||||
Object.assign(globalThis.WebSocket, OriginalWebSocket);
|
||||
globalThis.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
console.log(' + WebSocket = ws (debug-wrapped)');
|
||||
} catch (err) {
|
||||
console.warn(' ! ws 包未安装:', err.message);
|
||||
}
|
||||
|
||||
// XHR polyfill
|
||||
try {
|
||||
const { XMLHttpRequest } = require('xhr2');
|
||||
globalThis.XMLHttpRequest = XMLHttpRequest;
|
||||
const baseUrl = config?.restServer || 'https://a1.easemob.com';
|
||||
const origOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
|
||||
let finalUrl = url;
|
||||
if (typeof url === 'string' && url.startsWith('/')) {
|
||||
finalUrl = baseUrl + url;
|
||||
}
|
||||
return origOpen.call(this, method, finalUrl, ...rest);
|
||||
};
|
||||
console.log(' + XMLHttpRequest = xhr2');
|
||||
} catch {
|
||||
console.warn(' ! xhr2 包未安装');
|
||||
}
|
||||
|
||||
// Step 3: 加载 SDK
|
||||
console.log('\n[Step 3] 加载 easemob-websdk...');
|
||||
let websdk;
|
||||
try {
|
||||
const rawSdk = require('easemob-websdk');
|
||||
websdk = rawSdk.default || rawSdk;
|
||||
console.log(' SDK 加载成功 ✓');
|
||||
} catch (err) {
|
||||
console.error('✗ SDK 加载失败:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 4: 创建两个连接
|
||||
console.log('\n[Step 4] 创建双用户连接...');
|
||||
|
||||
const botCtx = createConnection(ACCOUNTS.bot, websdk, config);
|
||||
const testerCtx = createConnection(ACCOUNTS.tester, websdk, config);
|
||||
|
||||
console.log(' 两个连接实例创建成功 ✓');
|
||||
|
||||
// Step 5: 双方登录
|
||||
console.log('\n[Step 5] 双方登录...');
|
||||
|
||||
const loginPromises = [
|
||||
(async () => {
|
||||
try {
|
||||
await botCtx.conn.open({ user: ACCOUNTS.bot.user, pwd: ACCOUNTS.bot.pwd });
|
||||
} catch (err) {
|
||||
console.error(` [bot] 登录失败: ${err.message}`);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
await testerCtx.conn.open({ user: ACCOUNTS.tester.user, pwd: ACCOUNTS.tester.pwd });
|
||||
} catch (err) {
|
||||
console.error(` [tester] 登录失败: ${err.message}`);
|
||||
}
|
||||
})(),
|
||||
];
|
||||
|
||||
await Promise.all(loginPromises);
|
||||
|
||||
// 等待连接建立
|
||||
console.log('\n 等待双方连接建立...');
|
||||
await delay(5000);
|
||||
|
||||
// Step 6: 双向发消息
|
||||
console.log('\n[Step 6] 开始双向消息测试...\n');
|
||||
|
||||
// tester -> bot
|
||||
console.log('--- 测试 1: testuser1 -> originclaw_bot ---');
|
||||
sendTextMessage(
|
||||
testerCtx.conn, websdk,
|
||||
ACCOUNTS.bot.user, customMsg,
|
||||
testerCtx.account.label, testerCtx.stats
|
||||
);
|
||||
|
||||
await delay(3000);
|
||||
|
||||
// bot -> tester
|
||||
console.log('\n--- 测试 2: originclaw_bot -> testuser1 ---');
|
||||
sendTextMessage(
|
||||
botCtx.conn, websdk,
|
||||
ACCOUNTS.tester.user, `[Bot] ${customMsg}`,
|
||||
botCtx.account.label, botCtx.stats
|
||||
);
|
||||
|
||||
await delay(3000);
|
||||
|
||||
// Step 7: 统计结果
|
||||
console.log('\n\n=== 测试结果 ===');
|
||||
console.log(` [${botCtx.account.label}] 发送: ${botCtx.stats.sent}, 接收: ${botCtx.stats.received}`);
|
||||
console.log(` [${testerCtx.account.label}] 发送: ${testerCtx.stats.sent}, 接收: ${testerCtx.stats.received}`);
|
||||
console.log('\n保持连接中... (Ctrl+C 退出)\n');
|
||||
|
||||
// 优雅退出
|
||||
const cleanup = () => {
|
||||
console.log('\n=== 退出统计 ===');
|
||||
console.log(` [${botCtx.account.label}] 发送: ${botCtx.stats.sent}, 接收: ${botCtx.stats.received}`);
|
||||
console.log(` [${testerCtx.account.label}] 发送: ${testerCtx.stats.sent}, 接收: ${testerCtx.stats.received}`);
|
||||
try { botCtx.conn.close(); } catch {}
|
||||
try { testerCtx.conn.close(); } catch {}
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
}
|
||||
|
||||
// 防止未处理的 Promise 拒绝导致进程崩溃
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('⚠ unhandledRejection:', reason?.message || reason);
|
||||
});
|
||||
|
||||
// ─── 启动 ───
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
219
scripts/test-easemob-polyfill.js
Normal file
219
scripts/test-easemob-polyfill.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 环信 SDK Node.js Polyfill 测试脚本
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/test-easemob-polyfill.js [方案] [appKey] [user] [pwd] [restServer] [socketServer]
|
||||
*
|
||||
* 方案:
|
||||
* manual - 手动 polyfill (self + ws + localStorage)
|
||||
* happydom - 使用 happy-dom 注入完整浏览器环境
|
||||
* xhr2 - 手动 polyfill + xhr2 包 (推荐)
|
||||
*
|
||||
* 私有云配置:
|
||||
* restServer - REST API 服务器地址 (如 http://a1.easemob.com)
|
||||
* socketServer - WebSocket 服务器地址 (如 ws://im-api-v2.easemob.com/ws)
|
||||
*
|
||||
* 示例:
|
||||
* node scripts/test-easemob-polyfill.js xhr2 "your#appkey" "username" "password"
|
||||
* node scripts/test-easemob-polyfill.js xhr2 "your#appkey" "username" "password" "http://a1.easemob.com" "ws://im-api-v2.easemob.com/ws"
|
||||
*/
|
||||
|
||||
const { applyManualPolyfill, applyHappyDomPolyfill, printGlobalState } = require('./easemob-polyfill');
|
||||
|
||||
const [,, method = 'xhr2', appKey, user, pwd, restServer, socketServer] = process.argv;
|
||||
|
||||
if (!appKey || !user || !pwd) {
|
||||
console.log('用法: node scripts/test-easemob-polyfill.js <manual|xhr2|happydom> <appKey> <user> <pwd> [restServer] [socketServer]');
|
||||
console.log('');
|
||||
console.log('方案说明:');
|
||||
console.log(' manual - 手动 polyfill (无 XMLHttpRequest)');
|
||||
console.log(' xhr2 - 手动 polyfill + xhr2 包 (推荐)');
|
||||
console.log(' happydom - 使用 happy-dom 注入完整浏览器环境');
|
||||
console.log('');
|
||||
console.log('私有云参数:');
|
||||
console.log(' restServer - REST API 地址 (如 http://a1.easemob.com)');
|
||||
console.log(' socketServer - WebSocket 地址 (如 ws://im-api-v2.easemob.com/ws)');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' # 公有云:');
|
||||
console.log(' node scripts/test-easemob-polyfill.js xhr2 "org#app" "user1" "pass1"');
|
||||
console.log(' # 私有云:');
|
||||
console.log(' node scripts/test-easemob-polyfill.js xhr2 "org#app" "user1" "pass1" "http://a1.easemob.com" "ws://im-api-v2.easemob.com/ws"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resolvedRest = restServer || 'https://a1.easemob.com';
|
||||
const resolvedSocket = socketServer || 'wss://im-api-v2.easemob.com/ws';
|
||||
|
||||
console.log('=== 环信 SDK Node.js Polyfill 测试 ===');
|
||||
console.log(`方案: ${method}`);
|
||||
console.log(`AppKey: ${appKey}`);
|
||||
console.log(`User: ${user}`);
|
||||
console.log(`REST Server: ${resolvedRest}`);
|
||||
console.log(`Socket Server: ${resolvedSocket}`);
|
||||
console.log('');
|
||||
|
||||
async function main() {
|
||||
// ─── Step 1: 应用 polyfill ───
|
||||
console.log('[Step 1] 应用 polyfill...');
|
||||
|
||||
if (method === 'happydom') {
|
||||
await applyHappyDomPolyfill();
|
||||
} else {
|
||||
applyManualPolyfill();
|
||||
// 按 method 选择 XHR polyfill
|
||||
if (method === 'xhr2') {
|
||||
try {
|
||||
const { XMLHttpRequest } = require('xhr2');
|
||||
globalThis.XMLHttpRequest = XMLHttpRequest;
|
||||
console.log(' + XMLHttpRequest = xhr2');
|
||||
} catch {
|
||||
console.warn(' ! xhr2 包未安装,跳过');
|
||||
}
|
||||
} else if (method === 'xhr1') {
|
||||
try {
|
||||
const { XMLHttpRequest } = require('xmlhttprequest');
|
||||
globalThis.XMLHttpRequest = XMLHttpRequest;
|
||||
console.log(' + XMLHttpRequest = xmlhttprequest (旧版)');
|
||||
} catch {
|
||||
console.warn(' ! xmlhttprequest 包未安装,跳过');
|
||||
}
|
||||
}
|
||||
// manual 方案不注入 XMLHttpRequest
|
||||
|
||||
// Patch XHR.open: SDK 用相对路径,Node.js XHR 需要绝对 URL
|
||||
if (globalThis.XMLHttpRequest) {
|
||||
const BASE_URL = resolvedRest;
|
||||
const origOpen = globalThis.XMLHttpRequest.prototype.open;
|
||||
globalThis.XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
|
||||
let finalUrl = url;
|
||||
if (typeof url === 'string' && url.startsWith('/')) {
|
||||
finalUrl = BASE_URL + url;
|
||||
console.log(` >>> XHR patch: ${method} ${url} -> ${finalUrl}`);
|
||||
}
|
||||
return origOpen.call(this, method, finalUrl, ...rest);
|
||||
};
|
||||
console.log(' + XHR.open patched (相对路径 -> ' + BASE_URL + ')');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Step 1] Polyfill 完成 ✓');
|
||||
printGlobalState();
|
||||
|
||||
// ─── Step 2: 加载 SDK ───
|
||||
console.log('\n[Step 2] 加载 easemob-websdk...');
|
||||
let websdk;
|
||||
try {
|
||||
const rawSdk = require('easemob-websdk');
|
||||
websdk = rawSdk.default || rawSdk;
|
||||
console.log('[Step 2] SDK 加载成功 ✓');
|
||||
console.log(` - SDK keys: ${Object.keys(websdk).slice(0, 10).join(', ')}`);
|
||||
console.log(` - Has connection: ${typeof websdk.connection}`);
|
||||
console.log(` - Has message: ${typeof websdk.message}`);
|
||||
} catch (err) {
|
||||
console.error('[Step 2] SDK 加载失败 ✗');
|
||||
console.error(` Error: ${err.message}`);
|
||||
console.error(` Stack: ${err.stack}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── Step 3: 创建连接实例 ───
|
||||
console.log('\n[Step 3] 创建连接实例...');
|
||||
let conn;
|
||||
try {
|
||||
conn = new websdk.connection({
|
||||
appKey,
|
||||
isHttpDNS: false,
|
||||
restUrl: resolvedRest,
|
||||
msyncUrl: resolvedSocket,
|
||||
});
|
||||
console.log('[Step 3] 连接实例创建成功 ✓');
|
||||
console.log(` - conn type: ${typeof conn}`);
|
||||
console.log(` - Has open: ${typeof conn.open}`);
|
||||
console.log(` - Has listen: ${typeof conn.listen}`);
|
||||
console.log(` - Has close: ${typeof conn.close}`);
|
||||
} catch (err) {
|
||||
console.error('[Step 3] 创建连接实例失败 ✗');
|
||||
console.error(` Error: ${err.message}`);
|
||||
console.error(` Stack: ${err.stack}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── Step 4: 注册事件监听 ───
|
||||
console.log('\n[Step 4] 注册事件监听...');
|
||||
try {
|
||||
conn.listen({
|
||||
onOpened: () => {
|
||||
console.log('\n★ [Event] 连接已打开 (onOpened) ★');
|
||||
printGlobalState();
|
||||
console.log('');
|
||||
console.log('=== 测试成功!环信 SDK 在 Node.js 下正常工作 ===');
|
||||
console.log('5 秒后自动断开...');
|
||||
setTimeout(() => {
|
||||
try { conn.close(); } catch (e) { /* ignore */ }
|
||||
setTimeout(() => process.exit(0), 1000);
|
||||
}, 5000);
|
||||
},
|
||||
onClosed: () => {
|
||||
console.log('[Event] 连接已关闭 (onClosed)');
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMsg = error?.message || error?.data?.error_description || JSON.stringify(error);
|
||||
console.error(`\n✗ [Event] 错误 (onError): ${errMsg}`);
|
||||
// 不退出,有些错误之后还能重连
|
||||
},
|
||||
onTextMessage: (message) => {
|
||||
console.log(`\n[Event] 收到文本消息: ${JSON.stringify(message).substring(0, 200)}`);
|
||||
},
|
||||
onPictureMessage: (message) => {
|
||||
console.log(`\n[Event] 收到图片消息: from=${message.from}`);
|
||||
},
|
||||
onAudioMessage: (message) => {
|
||||
console.log(`\n[Event] 收到语音消息: from=${message.from}`);
|
||||
},
|
||||
onVideoMessage: (message) => {
|
||||
console.log(`\n[Event] 收到视频消息: from=${message.from}`);
|
||||
},
|
||||
onFileMessage: (message) => {
|
||||
console.log(`\n[Event] 收到文件消息: from=${message.from}`);
|
||||
},
|
||||
onPresence: (message) => {
|
||||
console.log(`[Event] Presence: ${message?.type}`);
|
||||
},
|
||||
});
|
||||
console.log('[Step 4] 事件监听注册成功 ✓');
|
||||
} catch (err) {
|
||||
console.error('[Step 4] 注册事件监听失败 ✗');
|
||||
console.error(` Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── Step 5: 登录 ───
|
||||
console.log('\n[Step 5] 开始登录...');
|
||||
console.log(` user: ${user}`);
|
||||
try {
|
||||
await conn.open({ user, pwd });
|
||||
console.log('[Step 5] open() 已调用,等待 onOpened 回调...');
|
||||
} catch (err) {
|
||||
console.error('[Step 5] 登录失败 ✗');
|
||||
console.error(` Error: ${err.message}`);
|
||||
console.error(` Stack: ${err.stack}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 超时保护:30 秒无 onOpened 则退出
|
||||
setTimeout(() => {
|
||||
console.error('\n✗ 超时:30 秒内未收到 onOpened 回调');
|
||||
console.error(' 可能原因:');
|
||||
console.error(' 1. WebSocket 连接失败(检查网络/防火墙)');
|
||||
console.error(' 2. Polyfill 不完整(缺少必要的浏览器 API)');
|
||||
console.error(' 3. 账号密码错误');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// ─── 启动 ───
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user