� backup: 2026-03-24 04:00

This commit is contained in:
huan
2026-03-24 04:00:48 +08:00
parent 7e143d3ebc
commit 31786dee08
193 changed files with 73520 additions and 1915 deletions
+207
View File
@@ -0,0 +1,207 @@
# @ynhcj/xiaoyichannel
XiaoYi channel plugin for OpenClaw with A2A protocol support.
## Features
- WebSocket-based connection to XiaoYi servers
- AK/SK authentication mechanism
- A2A (Agent-to-Agent) message protocol support
- Automatic reconnection with exponential backoff
- Heartbeat mechanism for connection health monitoring
- Full integration with OpenClaw's message routing and session management
## Installation
Install the plugin in your OpenClaw project:
```bash
openclaw plugins install @ynhcj/xiaoyichannel@1.0.0
```
## Configuration
After installation, add the XiaoYi channel configuration to your `openclaw.json` (or `.openclawd.json`):
```json
{
"channels": {
"xiaoyi": {
"enabled": true,
"accounts": {
"default": {
"enabled": true,
"wsUrl": "wss://hag.com/ws/link",
"ak": "your-access-key",
"sk": "your-secret-key",
"agentId": "your-agent-id"
}
}
}
},
"agents": {
"bindings": [
{
"agentId": "main",
"match": {
"channel": "xiaoyi",
"accountId": "default"
}
}
]
}
}
```
### Configuration Parameters
- `wsUrl`: WebSocket server URL (e.g., `wss://hag.com/ws/link`)
- `ak`: Access Key for authentication
- `sk`: Secret Key for authentication
- `agentId`: Your agent identifier
### Multiple Accounts
You can configure multiple XiaoYi accounts:
```json
{
"channels": {
"xiaoyi": {
"enabled": true,
"accounts": {
"account1": {
"enabled": true,
"wsUrl": "wss://hag.com/ws/link",
"ak": "ak1",
"sk": "sk1",
"agentId": "agent1"
},
"account2": {
"enabled": true,
"wsUrl": "wss://hag.com/ws/link",
"ak": "ak2",
"sk": "sk2",
"agentId": "agent2"
}
}
}
}
}
```
## A2A Protocol
This plugin implements the A2A (Agent-to-Agent) message protocol as specified in the [Huawei Message Stream documentation](https://developer.huawei.com/consumer/cn/doc/service/message-stream-0000002505761434).
### Message Structure
**Incoming Request Message:**
```json
{
"sessionId": "session-123",
"messageId": "msg-456",
"timestamp": 1234567890,
"sender": {
"id": "user-id",
"name": "User Name",
"type": "user"
},
"content": {
"type": "text",
"text": "Hello, agent!"
}
}
```
**Outgoing Response Message:**
```json
{
"sessionId": "session-123",
"messageId": "msg-789",
"timestamp": 1234567891,
"agentId": "your-agent-id",
"sender": {
"id": "your-agent-id",
"name": "OpenClaw Agent",
"type": "agent"
},
"content": {
"type": "text",
"text": "Hello! How can I help you?"
},
"status": "success"
}
```
## Authentication
The plugin uses AK/SK authentication as specified in the [Huawei Push Message documentation](https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436).
The authentication signature is generated using HMAC-SHA256:
```
signature = HMAC-SHA256(SK, "ak={AK}&timestamp={TIMESTAMP}")
```
## Connection Management
The plugin automatically manages WebSocket connections with the following features:
- **Automatic Reconnection**: Reconnects automatically on connection loss with exponential backoff
- **Heartbeat Monitoring**: Sends ping messages every 30 seconds to keep the connection alive
- **Connection Health**: Monitors connection status and reports health via OpenClaw's status system
- **Max Retry Limit**: Stops reconnection attempts after 10 failed attempts
## Session Management
The plugin integrates with OpenClaw's session management system:
- Sessions are scoped by `sessionId` from incoming A2A messages
- Each conversation maintains its own session context
- Session keys are automatically generated based on OpenClaw's configuration
## Usage
Once configured, the plugin will:
1. Automatically connect to the XiaoYi WebSocket server on startup
2. Authenticate using the provided AK/SK credentials
3. Receive incoming messages via WebSocket
4. Route messages to the appropriate OpenClaw agent
5. Send agent responses back through the WebSocket connection
## Troubleshooting
### Connection Issues
Check the OpenClaw logs for connection status:
```bash
openclaw logs
```
### Authentication Failures
Verify your AK/SK credentials are correct and have the necessary permissions.
### Message Delivery
Ensure your `agentId` is correctly configured and matches your XiaoYi account settings.
## Development
To build the plugin from source:
```bash
npm install
npm run build
```
## License
MIT
## Support
For issues and questions, please visit the [GitHub repository](https://github.com/ynhcj/xiaoyichannel).
+36
View File
@@ -0,0 +1,36 @@
import { AuthCredentials } from "./types";
/**
* Generate authentication signature using AK/SK mechanism
* Based on: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
*
* Signature format: Base64(HMAC-SHA256(secretKey, ts))
*/
export declare class XiaoYiAuth {
private ak;
private sk;
private agentId;
constructor(ak: string, sk: string, agentId: string);
/**
* Generate authentication credentials with signature
*/
generateAuthCredentials(): AuthCredentials;
/**
* Generate HMAC-SHA256 signature
* Format: Base64(HMAC-SHA256(secretKey, ts))
* Reference: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
* @param timestamp - Timestamp as string (e.g., "1514764800000")
*/
private generateSignature;
/**
* Verify if credentials are valid
*/
verifyCredentials(credentials: AuthCredentials): boolean;
/**
* Generate authentication headers for WebSocket connection
*/
generateAuthHeaders(): Record<string, string>;
/**
* Generate authentication message for WebSocket (legacy, kept for compatibility)
*/
generateAuthMessage(): any;
}
+111
View File
@@ -0,0 +1,111 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiAuth = void 0;
const crypto = __importStar(require("crypto"));
/**
* Generate authentication signature using AK/SK mechanism
* Based on: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
*
* Signature format: Base64(HMAC-SHA256(secretKey, ts))
*/
class XiaoYiAuth {
constructor(ak, sk, agentId) {
this.ak = ak;
this.sk = sk;
this.agentId = agentId;
}
/**
* Generate authentication credentials with signature
*/
generateAuthCredentials() {
const timestamp = Date.now();
const signature = this.generateSignature(timestamp.toString());
return {
ak: this.ak,
sk: this.sk,
timestamp,
signature,
};
}
/**
* Generate HMAC-SHA256 signature
* Format: Base64(HMAC-SHA256(secretKey, ts))
* Reference: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
* @param timestamp - Timestamp as string (e.g., "1514764800000")
*/
generateSignature(timestamp) {
// HMAC-SHA256(secretKey, ts)
const hmac = crypto.createHmac("sha256", this.sk);
hmac.update(timestamp);
const digest = hmac.digest();
// Base64 encode
return digest.toString("base64");
}
/**
* Verify if credentials are valid
*/
verifyCredentials(credentials) {
const expectedSignature = this.generateSignature(credentials.timestamp.toString());
return credentials.signature === expectedSignature;
}
/**
* Generate authentication headers for WebSocket connection
*/
generateAuthHeaders() {
const timestamp = Date.now();
const signature = this.generateSignature(timestamp.toString());
return {
"x-access-key": this.ak,
"x-sign": signature,
"x-ts": timestamp.toString(),
"x-agent-id": this.agentId,
};
}
/**
* Generate authentication message for WebSocket (legacy, kept for compatibility)
*/
generateAuthMessage() {
const credentials = this.generateAuthCredentials();
return {
type: "auth",
ak: credentials.ak,
agentId: this.agentId,
timestamp: credentials.timestamp,
signature: credentials.signature,
};
}
}
exports.XiaoYiAuth = XiaoYiAuth;
+144
View File
@@ -0,0 +1,144 @@
import type { ChannelOutboundContext, OutboundDeliveryResult, ChannelGatewayContext, ChannelMessagingNormalizeTargetContext, ChannelStatusGetAccountStatusContext, OpenClawConfig } from "openclaw";
import { XiaoYiChannelConfig } from "./types";
/**
* Resolved XiaoYi account configuration (single account mode)
*/
export interface ResolvedXiaoYiAccount {
accountId: string;
config: XiaoYiChannelConfig;
}
/**
* XiaoYi Channel Plugin
* Implements OpenClaw ChannelPlugin interface for XiaoYi A2A protocol
* Single account mode only
*/
export declare const xiaoyiPlugin: {
id: string;
meta: {
id: string;
label: string;
selectionLabel: string;
docsPath: string;
blurb: string;
aliases: string[];
};
capabilities: {
chatTypes: string[];
polls: boolean;
reactions: boolean;
threads: boolean;
media: boolean;
nativeCommands: boolean;
};
/**
* Config schema for UI form rendering
*/
configSchema: {
schema: {
type: string;
properties: {
enabled: {
type: string;
default: boolean;
description: string;
};
wsUrl1: {
type: string;
default: string;
description: string;
};
wsUrl2: {
type: string;
default: string;
description: string;
};
ak: {
type: string;
description: string;
};
sk: {
type: string;
description: string;
};
agentId: {
type: string;
description: string;
};
debug: {
type: string;
default: boolean;
description: string;
};
apiId: {
type: string;
default: string;
description: string;
};
pushId: {
type: string;
default: string;
description: string;
};
taskTimeoutMs: {
type: string;
default: number;
description: string;
};
};
};
};
onboarding: any;
/**
* Config adapter - single account mode
*/
config: {
listAccountIds: (cfg: OpenClawConfig) => string[];
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
accountId: string;
config: XiaoYiChannelConfig;
enabled: boolean;
};
defaultAccountId: (cfg: OpenClawConfig) => string;
isConfigured: (account: any, cfg: OpenClawConfig) => boolean;
isEnabled: (account: any, cfg: OpenClawConfig) => boolean;
disabledReason: (account: any, cfg: OpenClawConfig) => string;
unconfiguredReason: (account: any, cfg: OpenClawConfig) => string;
describeAccount: (account: any, cfg: OpenClawConfig) => {
accountId: any;
name: string;
enabled: any;
configured: boolean;
};
};
/**
* Outbound adapter - send messages
*/
outbound: {
deliveryMode: string;
textChunkLimit: number;
sendText: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
};
/**
* Gateway adapter - manage connections
*/
gateway: {
startAccount: (ctx: ChannelGatewayContext<ResolvedXiaoYiAccount>) => Promise<void>;
stopAccount: (ctx: ChannelGatewayContext<ResolvedXiaoYiAccount>) => Promise<void>;
};
/**
* Messaging adapter - normalize targets
*/
messaging: {
normalizeTarget: (ctx: ChannelMessagingNormalizeTargetContext) => Promise<string>;
};
/**
* Status adapter - health checks
*/
status: {
getAccountStatus: (ctx: ChannelStatusGetAccountStatusContext) => Promise<{
status: string;
message: string;
}>;
};
};
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
/**
* XiaoYi configuration schema using Zod
* Defines the structure for XiaoYi A2A protocol configuration
*/
export declare const XiaoYiConfigSchema: z.ZodObject<{
/** Account name (optional display name) */
name: z.ZodOptional<z.ZodString>;
/** Whether this channel is enabled */
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
/** First WebSocket server URL */
wsUrl1: z.ZodDefault<z.ZodOptional<z.ZodString>>;
/** Second WebSocket server URL */
wsUrl2: z.ZodDefault<z.ZodOptional<z.ZodString>>;
/** Access Key for authentication */
ak: z.ZodOptional<z.ZodString>;
/** Secret Key for authentication */
sk: z.ZodOptional<z.ZodString>;
/** Agent ID for this XiaoYi agent */
agentId: z.ZodOptional<z.ZodString>;
/** Enable debug logging */
debug: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
/** Multi-account configuration */
accounts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, "strip", z.ZodTypeAny, {
enabled?: boolean;
wsUrl1?: string;
wsUrl2?: string;
ak?: string;
sk?: string;
agentId?: string;
name?: string;
debug?: boolean;
accounts?: Record<string, unknown>;
}, {
enabled?: boolean;
wsUrl1?: string;
wsUrl2?: string;
ak?: string;
sk?: string;
agentId?: string;
name?: string;
debug?: boolean;
accounts?: Record<string, unknown>;
}>;
export type XiaoYiConfig = z.infer<typeof XiaoYiConfigSchema>;
+28
View File
@@ -0,0 +1,28 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiConfigSchema = void 0;
const zod_1 = require("zod");
/**
* XiaoYi configuration schema using Zod
* Defines the structure for XiaoYi A2A protocol configuration
*/
exports.XiaoYiConfigSchema = zod_1.z.object({
/** Account name (optional display name) */
name: zod_1.z.string().optional(),
/** Whether this channel is enabled */
enabled: zod_1.z.boolean().optional().default(false),
/** First WebSocket server URL */
wsUrl1: zod_1.z.string().optional().default("wss://hag.cloud.huawei.com/openclaw/v1/ws/link"),
/** Second WebSocket server URL */
wsUrl2: zod_1.z.string().optional().default("wss://116.63.174.231/openclaw/v1/ws/link"),
/** Access Key for authentication */
ak: zod_1.z.string().optional(),
/** Secret Key for authentication */
sk: zod_1.z.string().optional(),
/** Agent ID for this XiaoYi agent */
agentId: zod_1.z.string().optional(),
/** Enable debug logging */
debug: zod_1.z.boolean().optional().default(false),
/** Multi-account configuration */
accounts: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Simple file and image handler for XiaoYi Channel
* Handles downloading and extracting content from URIs
*/
export interface InputImageContent {
type: "image";
data: string;
mimeType: string;
}
export interface ImageLimits {
allowUrl: boolean;
allowedMimes: Set<string>;
maxBytes: number;
maxRedirects: number;
timeoutMs: number;
}
/**
* Extract image content from URL
*/
export declare function extractImageFromUrl(url: string, limits?: Partial<ImageLimits>): Promise<InputImageContent>;
/**
* Extract text content from URL (for text-based files)
*/
export declare function extractTextFromUrl(url: string, maxBytes?: number, timeoutMs?: number): Promise<string>;
/**
* Check if a MIME type is an image
*/
export declare function isImageMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is a PDF
*/
export declare function isPdfMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is text-based
*/
export declare function isTextMimeType(mimeType: string | undefined): boolean;
+113
View File
@@ -0,0 +1,113 @@
"use strict";
/**
* Simple file and image handler for XiaoYi Channel
* Handles downloading and extracting content from URIs
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractImageFromUrl = extractImageFromUrl;
exports.extractTextFromUrl = extractTextFromUrl;
exports.isImageMimeType = isImageMimeType;
exports.isPdfMimeType = isPdfMimeType;
exports.isTextMimeType = isTextMimeType;
// Default limits
const DEFAULT_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const DEFAULT_MAX_BYTES = 10000000; // 10MB
const DEFAULT_TIMEOUT = 30000; // 30 seconds
const DEFAULT_MAX_REDIRECTS = 3;
/**
* Fetch content from URL with basic validation
*/
async function fetchFromUrl(url, maxBytes, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "XiaoYi-Channel/1.0" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check content-length header if available
const contentLength = response.headers.get("content-length");
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size > maxBytes) {
throw new Error(`File too large: ${size} bytes (limit: ${maxBytes})`);
}
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > maxBytes) {
throw new Error(`File too large: ${buffer.byteLength} bytes (limit: ${maxBytes})`);
}
// Detect MIME type
const contentType = response.headers.get("content-type");
const mimeType = contentType?.split(";")[0]?.trim() || "application/octet-stream";
return { buffer, mimeType };
}
finally {
clearTimeout(timeout);
}
}
/**
* Extract image content from URL
*/
async function extractImageFromUrl(url, limits) {
const finalLimits = {
allowUrl: limits?.allowUrl ?? true,
allowedMimes: limits?.allowedMimes ?? DEFAULT_IMAGE_MIMES,
maxBytes: limits?.maxBytes ?? DEFAULT_MAX_BYTES,
maxRedirects: limits?.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
timeoutMs: limits?.timeoutMs ?? DEFAULT_TIMEOUT,
};
if (!finalLimits.allowUrl) {
throw new Error("URL sources are disabled");
}
const { buffer, mimeType } = await fetchFromUrl(url, finalLimits.maxBytes, finalLimits.timeoutMs);
if (!finalLimits.allowedMimes.has(mimeType)) {
throw new Error(`Unsupported image type: ${mimeType}`);
}
return {
type: "image",
data: buffer.toString("base64"),
mimeType,
};
}
/**
* Extract text content from URL (for text-based files)
*/
async function extractTextFromUrl(url, maxBytes = 5000000, timeoutMs = 30000) {
const { buffer, mimeType } = await fetchFromUrl(url, maxBytes, timeoutMs);
// Only process text-based MIME types
const textMimes = ["text/plain", "text/markdown", "text/html", "text/csv", "application/json", "application/xml"];
if (!textMimes.some((tm) => mimeType.startsWith(tm) || mimeType === tm)) {
throw new Error(`Unsupported text type: ${mimeType}`);
}
// Try to decode as UTF-8
return buffer.toString("utf-8");
}
/**
* Check if a MIME type is an image
*/
function isImageMimeType(mimeType) {
if (!mimeType)
return false;
return DEFAULT_IMAGE_MIMES.has(mimeType.toLowerCase());
}
/**
* Check if a MIME type is a PDF
*/
function isPdfMimeType(mimeType) {
return mimeType?.toLowerCase() === "application/pdf" || false;
}
/**
* Check if a MIME type is text-based
*/
function isTextMimeType(mimeType) {
if (!mimeType)
return false;
const lower = mimeType.toLowerCase();
return (lower.startsWith("text/") ||
lower === "application/json" ||
lower === "application/xml");
}
+29
View File
@@ -0,0 +1,29 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
/**
* XiaoYi Channel Plugin for OpenClaw
*
* This plugin enables integration with XiaoYi's A2A protocol via WebSocket.
* Supports dual server mode for high availability.
*
* Configuration example in openclaw.json:
* {
* "channels": {
* "xiaoyi": {
* "enabled": true,
* "wsUrl1": "ws://localhost:8765/ws/link",
* "wsUrl2": "ws://localhost:8766/ws/link",
* "ak": "test_ak",
* "sk": "test_sk",
* "agentId": "your-agent-id"
* }
* }
* }
*/
declare const plugin: {
id: string;
name: string;
description: string;
configSchema: any;
register(api: OpenClawPluginApi): void;
};
export default plugin;
+49
View File
@@ -0,0 +1,49 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const channel_1 = require("./channel");
const runtime_1 = require("./runtime");
/**
* XiaoYi Channel Plugin for OpenClaw
*
* This plugin enables integration with XiaoYi's A2A protocol via WebSocket.
* Supports dual server mode for high availability.
*
* Configuration example in openclaw.json:
* {
* "channels": {
* "xiaoyi": {
* "enabled": true,
* "wsUrl1": "ws://localhost:8765/ws/link",
* "wsUrl2": "ws://localhost:8766/ws/link",
* "ak": "test_ak",
* "sk": "test_sk",
* "agentId": "your-agent-id"
* }
* }
* }
*/
const plugin = {
id: "xiaoyi",
name: "XiaoYi Channel",
description: "XiaoYi channel plugin with A2A protocol support",
configSchema: undefined,
register(api) {
console.log("XiaoYi: register() called - START");
// Set runtime for managing WebSocket connections
(0, runtime_1.setXiaoYiRuntime)(api.runtime);
console.log("XiaoYi: setXiaoYiRuntime() completed");
// Clean up any existing connections from previous plugin loads
const runtime = require("./runtime").getXiaoYiRuntime();
console.log(`XiaoYi: Got runtime instance: ${runtime.getInstanceId()}, isConnected: ${runtime.isConnected()}`);
if (runtime.isConnected()) {
console.log("XiaoYi: Cleaning up existing connection from previous load");
runtime.stop();
}
// Register the channel plugin
console.log("XiaoYi: About to call registerChannel()");
api.registerChannel({ plugin: channel_1.xiaoyiPlugin });
console.log("XiaoYi: registerChannel() completed");
console.log("XiaoYi channel plugin registered - END");
},
};
exports.default = plugin;
+6
View File
@@ -0,0 +1,6 @@
/**
* XiaoYi onboarding adapter for CLI setup wizard.
*/
type ChannelOnboardingAdapter = any;
export declare const xiaoyiOnboardingAdapter: ChannelOnboardingAdapter;
export {};
+167
View File
@@ -0,0 +1,167 @@
"use strict";
/**
* XiaoYi onboarding adapter for CLI setup wizard.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.xiaoyiOnboardingAdapter = void 0;
const channel = "xiaoyi";
/**
* Get XiaoYi channel config from OpenClaw config
*/
function getXiaoYiConfig(cfg) {
return cfg?.channels?.xiaoyi;
}
/**
* Check if XiaoYi is properly configured
*/
function isXiaoYiConfigured(config) {
if (!config) {
return false;
}
// Check required fields: ak, sk, agentId
// wsUrl1/wsUrl2 are optional (defaults will be used if not provided)
const hasAk = typeof config.ak === "string" && config.ak.trim().length > 0;
const hasSk = typeof config.sk === "string" && config.sk.trim().length > 0;
const hasAgentId = typeof config.agentId === "string" && config.agentId.trim().length > 0;
return hasAk && hasSk && hasAgentId;
}
/**
* Set XiaoYi channel configuration
*/
function setXiaoYiConfig(cfg, config) {
const existing = getXiaoYiConfig(cfg);
const merged = {
enabled: config.enabled ?? existing?.enabled ?? true,
wsUrl: config.wsUrl ?? existing?.wsUrl ?? "",
wsUrl1: config.wsUrl1 ?? existing?.wsUrl1 ?? "",
wsUrl2: config.wsUrl2 ?? existing?.wsUrl2 ?? "",
ak: config.ak ?? existing?.ak ?? "",
sk: config.sk ?? existing?.sk ?? "",
agentId: config.agentId ?? existing?.agentId ?? "",
enableStreaming: config.enableStreaming ?? existing?.enableStreaming ?? true,
};
return {
...cfg,
channels: {
...cfg.channels,
xiaoyi: merged,
},
};
}
/**
* Note about XiaoYi setup
*/
async function noteXiaoYiSetupHelp(prompter) {
await prompter.note([
"XiaoYi (小艺) uses A2A protocol via WebSocket connection.",
"",
"Required credentials:",
" - ak: Access Key for authentication",
" - sk: Secret Key for authentication",
" - agentId: Your agent identifier",
"",
"WebSocket URLs will use default values.",
"",
"Docs: https://docs.openclaw.ai/channels/xiaoyi",
].join("\n"), "XiaoYi setup");
}
/**
* Prompt for Access Key
*/
async function promptAk(prompter, config) {
const existing = config?.ak ?? "";
return String(await prompter.text({
message: "XiaoYi Access Key (ak)",
initialValue: existing,
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
/**
* Prompt for Secret Key
*/
async function promptSk(prompter, config) {
const existing = config?.sk ?? "";
return String(await prompter.text({
message: "XiaoYi Secret Key (sk)",
initialValue: existing,
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
/**
* Prompt for Agent ID
*/
async function promptAgentId(prompter, config) {
const existing = config?.agentId ?? "";
return String(await prompter.text({
message: "XiaoYi Agent ID",
initialValue: existing,
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
exports.xiaoyiOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const config = getXiaoYiConfig(cfg);
const configured = isXiaoYiConfigured(config);
const enabled = config?.enabled !== false;
const statusLines = [];
if (configured) {
statusLines.push(`XiaoYi: ${enabled ? "enabled" : "disabled"}`);
if (config?.wsUrl1 || config?.wsUrl) {
statusLines.push(` WebSocket: ${config.wsUrl1 || config.wsUrl}`);
}
if (config?.wsUrl2) {
statusLines.push(` Secondary: ${config.wsUrl2}`);
}
if (config?.agentId) {
statusLines.push(` Agent ID: ${config.agentId}`);
}
}
else {
statusLines.push("XiaoYi: needs ak, sk, and agentId");
}
return {
channel,
configured,
statusLines,
selectionHint: configured ? "configured" : "needs setup",
quickstartScore: 50,
};
},
configure: async ({ cfg, prompter }) => {
const config = getXiaoYiConfig(cfg);
if (!isXiaoYiConfigured(config)) {
await noteXiaoYiSetupHelp(prompter);
}
else {
const reconfigure = await prompter.confirm({
message: "XiaoYi already configured. Reconfigure?",
initialValue: false,
});
if (!reconfigure) {
return { cfg, accountId: "default" };
}
}
// Prompt for required credentials
const ak = await promptAk(prompter, config);
const sk = await promptSk(prompter, config);
const agentId = await promptAgentId(prompter, config);
const cfgWithConfig = setXiaoYiConfig(cfg, {
ak,
sk,
agentId,
enabled: true,
});
return { cfg: cfgWithConfig, accountId: "default" };
},
disable: (cfg) => {
const xiaoyi = getXiaoYiConfig(cfg);
return {
...cfg,
channels: {
...cfg.channels,
xiaoyi: { ...xiaoyi, enabled: false },
},
};
},
};
+28
View File
@@ -0,0 +1,28 @@
import { XiaoYiChannelConfig } from "./types";
/**
* Push message sending service
* Sends notifications to XiaoYi clients via webhook API
*/
export declare class XiaoYiPushService {
private config;
private readonly pushUrl;
constructor(config: XiaoYiChannelConfig);
/**
* Check if push functionality is configured
*/
isConfigured(): boolean;
/**
* Generate HMAC-SHA256 signature
*/
private generateSignature;
/**
* Generate UUID
*/
private generateUUID;
/**
* Send push notification (with summary text)
* @param text - Summary text to send (e.g., first 30 characters)
* @param pushText - Push notification message (e.g., "任务已完成:xxx...")
*/
sendPush(text: string, pushText: string): Promise<boolean>;
}
+135
View File
@@ -0,0 +1,135 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiPushService = void 0;
const crypto = __importStar(require("crypto"));
/**
* Push message sending service
* Sends notifications to XiaoYi clients via webhook API
*/
class XiaoYiPushService {
constructor(config) {
this.pushUrl = "https://hag.cloud.huawei.com/open-ability-agent/v1/agent-webhook";
this.config = config;
}
/**
* Check if push functionality is configured
*/
isConfigured() {
return Boolean(this.config.apiId?.trim() &&
this.config.pushId?.trim() &&
this.config.ak?.trim() &&
this.config.sk?.trim());
}
/**
* Generate HMAC-SHA256 signature
*/
generateSignature(timestamp) {
const hmac = crypto.createHmac("sha256", this.config.sk);
hmac.update(timestamp);
return hmac.digest().toString("base64");
}
/**
* Generate UUID
*/
generateUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Send push notification (with summary text)
* @param text - Summary text to send (e.g., first 30 characters)
* @param pushText - Push notification message (e.g., "任务已完成:xxx...")
*/
async sendPush(text, pushText) {
if (!this.isConfigured()) {
console.log("[PUSH] Push not configured, skipping");
return false;
}
try {
const timestamp = Date.now().toString();
const signature = this.generateSignature(timestamp);
const messageId = this.generateUUID();
const payload = {
jsonrpc: "2.0",
id: messageId,
result: {
id: this.generateUUID(),
apiId: this.config.apiId,
pushId: this.config.pushId,
pushText: pushText,
kind: "task",
artifacts: [{
artifactId: this.generateUUID(),
parts: [{
kind: "text",
text: text, // Summary text
}]
}],
status: { state: "completed" }
}
};
console.log(`[PUSH] Sending push notification: ${pushText}`);
const response = await fetch(this.pushUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"x-hag-trace-id": this.generateUUID(),
"X-Access-Key": this.config.ak,
"X-Sign": signature,
"X-Ts": timestamp,
},
body: JSON.stringify(payload),
});
if (response.ok) {
console.log("[PUSH] Push notification sent successfully");
return true;
}
else {
console.error(`[PUSH] Failed: HTTP ${response.status}`);
return false;
}
}
catch (error) {
console.error("[PUSH] Error:", error);
return false;
}
}
}
exports.XiaoYiPushService = XiaoYiPushService;
+191
View File
@@ -0,0 +1,191 @@
import { XiaoYiWebSocketManager } from "./websocket";
import { XiaoYiChannelConfig } from "./types";
/**
* Timeout configuration
*/
export interface TimeoutConfig {
enabled: boolean;
duration: number;
message: string;
}
/**
* Runtime state for XiaoYi channel
* Manages single WebSocket connection (single account mode)
*/
export declare class XiaoYiRuntime {
private connection;
private pluginRuntime;
private config;
private sessionToTaskIdMap;
private instanceId;
private sessionTimeoutMap;
private sessionTimeoutSent;
private timeoutConfig;
private sessionAbortControllerMap;
private sessionActiveRunMap;
private sessionStartTimeMap;
private static readonly SESSION_STALE_TIMEOUT_MS;
private sessionTaskTimeoutMap;
private sessionPushPendingMap;
private taskTimeoutMs;
constructor();
getInstanceId(): string;
/**
* Set OpenClaw PluginRuntime (from api.runtime in register())
*/
setPluginRuntime(runtime: any): void;
/**
* Get OpenClaw PluginRuntime
*/
getPluginRuntime(): any;
/**
* Start connection (single account mode)
*/
start(config: XiaoYiChannelConfig): Promise<void>;
/**
* Stop connection
*/
stop(): void;
/**
* Set timeout configuration
*/
setTimeoutConfig(config: Partial<TimeoutConfig>): void;
/**
* Get timeout configuration
*/
getTimeoutConfig(): TimeoutConfig;
/**
* Set timeout for a session
* @param sessionId - Session ID
* @param callback - Function to call when timeout occurs
* @returns The interval ID (for cancellation)
*
* IMPORTANT: This now uses setInterval instead of setTimeout
* - First trigger: after 60 seconds
* - Subsequent triggers: every 60 seconds after that
* - Cleared when: response received, session completed, or explicitly cleared
*/
setTimeoutForSession(sessionId: string, callback: () => void): NodeJS.Timeout | undefined;
/**
* Clear timeout interval for a session
* @param sessionId - Session ID
*/
clearSessionTimeout(sessionId: string): void;
/**
* Check if timeout has been sent for a session
* @param sessionId - Session ID
*/
isSessionTimeout(sessionId: string): boolean;
/**
* Mark session as completed (clear timeout and timeout flag)
* @param sessionId - Session ID
*/
markSessionCompleted(sessionId: string): void;
/**
* Clear all timeout intervals
*/
clearAllTimeouts(): void;
/**
* Get WebSocket manager
*/
getConnection(): XiaoYiWebSocketManager | null;
/**
* Check if connected
*/
isConnected(): boolean;
/**
* Get configuration
*/
getConfig(): XiaoYiChannelConfig | null;
/**
* Set taskId for a session
*/
setTaskIdForSession(sessionId: string, taskId: string): void;
/**
* Get taskId for a session
*/
getTaskIdForSession(sessionId: string): string | undefined;
/**
* Clear taskId for a session
*/
clearTaskIdForSession(sessionId: string): void;
/**
* Create and register an AbortController for a session
* @param sessionId - Session ID
* @returns The AbortController and its signal, or null if session is busy
*/
createAbortControllerForSession(sessionId: string): {
controller: AbortController;
signal: AbortSignal;
} | null;
/**
* Check if a session has an active agent run
* If session is active but stale (超过 SESSION_STALE_TIMEOUT_MS), automatically clean up
* @param sessionId - Session ID
* @returns true if session is busy
*/
isSessionActive(sessionId: string): boolean;
/**
* Abort a session's agent run
* @param sessionId - Session ID
* @returns true if a controller was found and aborted, false otherwise
*/
abortSession(sessionId: string): boolean;
/**
* Check if a session has been aborted
* @param sessionId - Session ID
* @returns true if the session's abort signal was triggered
*/
isSessionAborted(sessionId: string): boolean;
/**
* Clear the AbortController for a session (call when agent completes successfully)
* @param sessionId - Session ID
*/
clearAbortControllerForSession(sessionId: string): void;
/**
* Clear all AbortControllers
*/
clearAllAbortControllers(): void;
/**
* Generate a composite key for session+task combination
* This ensures each task has its own push state, even within the same session
*/
private getPushStateKey;
/**
* Set task timeout time (from configuration)
*/
setTaskTimeout(timeoutMs: number): void;
/**
* Set a 1-hour task timeout timer for a session
* @returns timeout ID
*/
setTaskTimeoutForSession(sessionId: string, taskId: string, callback: (sessionId: string, taskId: string) => void): NodeJS.Timeout;
/**
* Clear the task timeout timer for a session
*/
clearTaskTimeoutForSession(sessionId: string): void;
/**
* Check if session+task is waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
isSessionWaitingForPush(sessionId: string, taskId?: string): boolean;
/**
* Mark session+task as waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
markSessionWaitingForPush(sessionId: string, taskId?: string): void;
/**
* Clear the waiting push state for a session+task
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
clearSessionWaitingForPush(sessionId: string, taskId?: string): void;
/**
* Clear all task timeout related state for a session
*/
clearTaskTimeoutState(sessionId: string): void;
}
export declare function getXiaoYiRuntime(): XiaoYiRuntime;
export declare function setXiaoYiRuntime(runtime: any): void;
+438
View File
@@ -0,0 +1,438 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiRuntime = void 0;
exports.getXiaoYiRuntime = getXiaoYiRuntime;
exports.setXiaoYiRuntime = setXiaoYiRuntime;
const websocket_1 = require("./websocket");
/**
* Default timeout configuration
*/
const DEFAULT_TIMEOUT_CONFIG = {
enabled: true,
duration: 60000, // 60 seconds
message: "任务正在处理中,请稍后",
};
/**
* Runtime state for XiaoYi channel
* Manages single WebSocket connection (single account mode)
*/
class XiaoYiRuntime {
constructor() {
this.connection = null;
this.pluginRuntime = null; // Store PluginRuntime from OpenClaw
this.config = null;
this.sessionToTaskIdMap = new Map(); // Map sessionId to taskId
// Timeout management
this.sessionTimeoutMap = new Map();
this.sessionTimeoutSent = new Set();
this.timeoutConfig = DEFAULT_TIMEOUT_CONFIG;
// AbortController management for canceling agent runs
this.sessionAbortControllerMap = new Map();
// Track if a session has an active agent run (for concurrent request detection)
this.sessionActiveRunMap = new Map();
// Track session start time for timeout detection
this.sessionStartTimeMap = new Map();
// 1-hour task timeout mechanism
this.sessionTaskTimeoutMap = new Map();
this.sessionPushPendingMap = new Map();
this.taskTimeoutMs = 3600000; // Default 1 hour
this.instanceId = `runtime_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
console.log(`XiaoYi: Created new runtime instance: ${this.instanceId}`);
}
getInstanceId() {
return this.instanceId;
}
/**
* Set OpenClaw PluginRuntime (from api.runtime in register())
*/
setPluginRuntime(runtime) {
console.log(`XiaoYi: [${this.instanceId}] Setting PluginRuntime`);
this.pluginRuntime = runtime;
}
/**
* Get OpenClaw PluginRuntime
*/
getPluginRuntime() {
return this.pluginRuntime;
}
/**
* Start connection (single account mode)
*/
async start(config) {
if (this.connection) {
console.log("XiaoYi channel already connected");
return;
}
this.config = config;
const manager = new websocket_1.XiaoYiWebSocketManager(config);
// Setup basic event handlers (message handling is done in channel.ts)
manager.on("error", (error) => {
console.error("XiaoYi channel error:", error);
});
manager.on("disconnected", () => {
console.log("XiaoYi channel disconnected");
});
manager.on("authenticated", () => {
console.log("XiaoYi channel authenticated");
});
manager.on("maxReconnectAttemptsReached", (serverId) => {
console.error(`XiaoYi channel ${serverId} max reconnect attempts reached`);
// Check if the other server is still connected and ready
const otherServerId = serverId === 'server1' ? 'server2' : 'server1';
const serverStates = manager.getServerStates();
const otherServerState = otherServerId === 'server1' ? serverStates.server1 : serverStates.server2;
if (otherServerState?.connected && otherServerState?.ready) {
console.warn(`[${otherServerId}] is still connected and ready, continuing in single-server mode`);
console.warn(`System will continue running with ${otherServerId} only`);
// Don't stop, continue with the other server
return;
}
// Only stop when both servers have failed
console.error("Both servers have reached max reconnect attempts, stopping connection");
console.error(`Server1: ${serverStates.server1.connected ? 'connected' : 'disconnected'}, Server2: ${serverStates.server2.connected ? 'connected' : 'disconnected'}`);
this.stop();
});
// Connect
await manager.connect();
this.connection = manager;
console.log("XiaoYi channel started");
}
/**
* Stop connection
*/
stop() {
if (this.connection) {
this.connection.disconnect();
this.connection = null;
console.log("XiaoYi channel stopped");
}
// Clear session mappings
this.sessionToTaskIdMap.clear();
// Clear all timeouts
this.clearAllTimeouts();
// Clear all abort controllers
this.clearAllAbortControllers();
// Clear all task timeout state
for (const sessionId of this.sessionTaskTimeoutMap.keys()) {
this.clearTaskTimeoutState(sessionId);
}
}
/**
* Set timeout configuration
*/
setTimeoutConfig(config) {
this.timeoutConfig = { ...this.timeoutConfig, ...config };
console.log(`XiaoYi: Timeout config updated:`, this.timeoutConfig);
}
/**
* Get timeout configuration
*/
getTimeoutConfig() {
return { ...this.timeoutConfig };
}
/**
* Set timeout for a session
* @param sessionId - Session ID
* @param callback - Function to call when timeout occurs
* @returns The interval ID (for cancellation)
*
* IMPORTANT: This now uses setInterval instead of setTimeout
* - First trigger: after 60 seconds
* - Subsequent triggers: every 60 seconds after that
* - Cleared when: response received, session completed, or explicitly cleared
*/
setTimeoutForSession(sessionId, callback) {
if (!this.timeoutConfig.enabled) {
console.log(`[TIMEOUT] Timeout disabled, skipping for session ${sessionId}`);
return undefined;
}
// Clear existing timeout AND timeout flag if any (reuse session scenario)
const hadExistingTimeout = this.sessionTimeoutMap.has(sessionId);
const hadSentTimeout = this.sessionTimeoutSent.has(sessionId);
this.clearSessionTimeout(sessionId);
// Clear the timeout sent flag to allow this session to timeout again
if (hadSentTimeout) {
this.sessionTimeoutSent.delete(sessionId);
console.log(`[TIMEOUT] Previous timeout flag cleared for session ${sessionId} (session reuse)`);
}
// Use setInterval for periodic timeout triggers
// First trigger after duration, then every duration after that
const intervalId = setInterval(() => {
console.log(`[TIMEOUT] Timeout triggered for session ${sessionId} (will trigger again in ${this.timeoutConfig.duration}ms if still active)`);
this.sessionTimeoutSent.add(sessionId);
callback();
}, this.timeoutConfig.duration);
this.sessionTimeoutMap.set(sessionId, intervalId);
const logSuffix = hadExistingTimeout ? " (replacing existing interval)" : "";
console.log(`[TIMEOUT] ${this.timeoutConfig.duration}ms periodic timeout started for session ${sessionId}${logSuffix}`);
return intervalId;
}
/**
* Clear timeout interval for a session
* @param sessionId - Session ID
*/
clearSessionTimeout(sessionId) {
const intervalId = this.sessionTimeoutMap.get(sessionId);
if (intervalId) {
clearInterval(intervalId);
this.sessionTimeoutMap.delete(sessionId);
console.log(`[TIMEOUT] Timeout interval cleared for session ${sessionId}`);
}
}
/**
* Check if timeout has been sent for a session
* @param sessionId - Session ID
*/
isSessionTimeout(sessionId) {
return this.sessionTimeoutSent.has(sessionId);
}
/**
* Mark session as completed (clear timeout and timeout flag)
* @param sessionId - Session ID
*/
markSessionCompleted(sessionId) {
this.clearSessionTimeout(sessionId);
this.sessionTimeoutSent.delete(sessionId);
console.log(`[TIMEOUT] Session ${sessionId} marked as completed`);
}
/**
* Clear all timeout intervals
*/
clearAllTimeouts() {
for (const [sessionId, intervalId] of this.sessionTimeoutMap.entries()) {
clearInterval(intervalId);
}
this.sessionTimeoutMap.clear();
this.sessionTimeoutSent.clear();
console.log("[TIMEOUT] All timeout intervals cleared");
}
/**
* Get WebSocket manager
*/
getConnection() {
return this.connection;
}
/**
* Check if connected
*/
isConnected() {
return this.connection ? this.connection.isReady() : false;
}
/**
* Get configuration
*/
getConfig() {
return this.config;
}
/**
* Set taskId for a session
*/
setTaskIdForSession(sessionId, taskId) {
this.sessionToTaskIdMap.set(sessionId, taskId);
}
/**
* Get taskId for a session
*/
getTaskIdForSession(sessionId) {
return this.sessionToTaskIdMap.get(sessionId);
}
/**
* Clear taskId for a session
*/
clearTaskIdForSession(sessionId) {
this.sessionToTaskIdMap.delete(sessionId);
}
/**
* Create and register an AbortController for a session
* @param sessionId - Session ID
* @returns The AbortController and its signal, or null if session is busy
*/
createAbortControllerForSession(sessionId) {
// Check if there's an active agent run for this session
if (this.sessionActiveRunMap.get(sessionId)) {
console.log(`[CONCURRENT] Session ${sessionId} has an active agent run, cannot create new AbortController`);
return null;
}
const controller = new AbortController();
this.sessionAbortControllerMap.set(sessionId, controller);
this.sessionActiveRunMap.set(sessionId, true);
this.sessionStartTimeMap.set(sessionId, Date.now());
console.log(`[ABORT] Created AbortController for session ${sessionId}`);
return { controller, signal: controller.signal };
}
/**
* Check if a session has an active agent run
* If session is active but stale (超过 SESSION_STALE_TIMEOUT_MS), automatically clean up
* @param sessionId - Session ID
* @returns true if session is busy
*/
isSessionActive(sessionId) {
const isActive = this.sessionActiveRunMap.get(sessionId) || false;
if (isActive) {
// Check if the session has been active for too long
const startTime = this.sessionStartTimeMap.get(sessionId);
if (startTime) {
const elapsed = Date.now() - startTime;
if (elapsed > XiaoYiRuntime.SESSION_STALE_TIMEOUT_MS) {
// Session is stale, auto-cleanup and return false
console.log(`[CONCURRENT] Session ${sessionId} is stale (active for ${elapsed}ms), auto-cleaning`);
this.clearAbortControllerForSession(sessionId);
this.clearTaskIdForSession(sessionId);
this.clearSessionTimeout(sessionId);
this.sessionStartTimeMap.delete(sessionId);
return false;
}
}
}
return isActive;
}
/**
* Abort a session's agent run
* @param sessionId - Session ID
* @returns true if a controller was found and aborted, false otherwise
*/
abortSession(sessionId) {
const controller = this.sessionAbortControllerMap.get(sessionId);
if (controller) {
console.log(`[ABORT] Aborting session ${sessionId}`);
controller.abort();
this.sessionAbortControllerMap.delete(sessionId);
return true;
}
console.log(`[ABORT] No AbortController found for session ${sessionId}`);
return false;
}
/**
* Check if a session has been aborted
* @param sessionId - Session ID
* @returns true if the session's abort signal was triggered
*/
isSessionAborted(sessionId) {
const controller = this.sessionAbortControllerMap.get(sessionId);
return controller ? controller.signal.aborted : false;
}
/**
* Clear the AbortController for a session (call when agent completes successfully)
* @param sessionId - Session ID
*/
clearAbortControllerForSession(sessionId) {
const controller = this.sessionAbortControllerMap.get(sessionId);
if (controller) {
this.sessionAbortControllerMap.delete(sessionId);
console.log(`[ABORT] Cleared AbortController for session ${sessionId}`);
}
// Also clear the active run flag
this.sessionActiveRunMap.delete(sessionId);
// Clear the session start time
this.sessionStartTimeMap.delete(sessionId);
console.log(`[CONCURRENT] Session ${sessionId} marked as inactive`);
}
/**
* Clear all AbortControllers
*/
clearAllAbortControllers() {
this.sessionAbortControllerMap.clear();
console.log("[ABORT] All AbortControllers cleared");
}
// ==================== PUSH STATE MANAGEMENT HELPERS ====================
/**
* Generate a composite key for session+task combination
* This ensures each task has its own push state, even within the same session
*/
getPushStateKey(sessionId, taskId) {
return `${sessionId}:${taskId}`;
}
// ==================== END PUSH STATE MANAGEMENT HELPERS ====================
// ==================== 1-HOUR TASK TIMEOUT METHODS ====================
/**
* Set task timeout time (from configuration)
*/
setTaskTimeout(timeoutMs) {
this.taskTimeoutMs = timeoutMs;
console.log(`[TASK TIMEOUT] Task timeout set to ${timeoutMs}ms`);
}
/**
* Set a 1-hour task timeout timer for a session
* @returns timeout ID
*/
setTaskTimeoutForSession(sessionId, taskId, callback) {
this.clearTaskTimeoutForSession(sessionId);
const timeoutId = setTimeout(() => {
console.log(`[TASK TIMEOUT] ${this.taskTimeoutMs}ms timeout triggered for session ${sessionId}, task ${taskId}`);
callback(sessionId, taskId);
}, this.taskTimeoutMs);
this.sessionTaskTimeoutMap.set(sessionId, timeoutId);
console.log(`[TASK TIMEOUT] ${this.taskTimeoutMs}ms task timeout started for session ${sessionId}`);
return timeoutId;
}
/**
* Clear the task timeout timer for a session
*/
clearTaskTimeoutForSession(sessionId) {
const timeoutId = this.sessionTaskTimeoutMap.get(sessionId);
if (timeoutId) {
clearTimeout(timeoutId);
this.sessionTaskTimeoutMap.delete(sessionId);
console.log(`[TASK TIMEOUT] Timeout cleared for session ${sessionId}`);
}
}
/**
* Check if session+task is waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
isSessionWaitingForPush(sessionId, taskId) {
const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
return this.sessionPushPendingMap.get(key) === true;
}
/**
* Mark session+task as waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
markSessionWaitingForPush(sessionId, taskId) {
const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
this.sessionPushPendingMap.set(key, true);
const taskInfo = taskId ? `, task ${taskId}` : '';
console.log(`[PUSH] Session ${sessionId}${taskInfo} marked as waiting for push`);
}
/**
* Clear the waiting push state for a session+task
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
clearSessionWaitingForPush(sessionId, taskId) {
const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
this.sessionPushPendingMap.delete(key);
const taskInfo = taskId ? `, task ${taskId}` : '';
console.log(`[PUSH] Session ${sessionId}${taskInfo} cleared from waiting for push`);
}
/**
* Clear all task timeout related state for a session
*/
clearTaskTimeoutState(sessionId) {
this.clearTaskTimeoutForSession(sessionId);
this.clearSessionWaitingForPush(sessionId);
console.log(`[TASK TIMEOUT] All timeout state cleared for session ${sessionId}`);
}
}
exports.XiaoYiRuntime = XiaoYiRuntime;
// Maximum time a session can be active before we consider it stale (5 minutes)
XiaoYiRuntime.SESSION_STALE_TIMEOUT_MS = 5 * 60 * 1000;
// Global runtime instance - use global object to survive module reloads
// CRITICAL: Use string key instead of Symbol to ensure consistency across module reloads
const GLOBAL_KEY = '__xiaoyi_runtime_instance__';
function getXiaoYiRuntime() {
const g = global;
if (!g[GLOBAL_KEY]) {
console.log("XiaoYi: Creating NEW runtime instance (global storage)");
g[GLOBAL_KEY] = new XiaoYiRuntime();
}
else {
console.log(`XiaoYi: Reusing EXISTING runtime instance: ${g[GLOBAL_KEY].getInstanceId()}`);
}
return g[GLOBAL_KEY];
}
function setXiaoYiRuntime(runtime) {
getXiaoYiRuntime().setPluginRuntime(runtime);
}
+207
View File
@@ -0,0 +1,207 @@
export interface A2ARequestMessage {
agentId: string;
jsonrpc: "2.0";
id: string;
method: "message/stream";
deviceId?: string;
conversationId?: string;
sessionId?: string;
params: {
id: string;
sessionId?: string;
agentLoginSessionId?: string;
message: {
kind?: string;
messageId?: string;
role: "user" | "agent";
parts: Array<{
kind: "text" | "file" | "data";
text?: string;
file?: {
name: string;
mimeType: string;
bytes?: string;
uri?: string;
};
data?: any;
}>;
};
};
}
export interface A2AResponseMessage {
sessionId: string;
messageId: string;
timestamp: number;
agentId: string;
sender: {
id: string;
name?: string;
type: "agent";
};
content: {
type: "text" | "image" | "audio" | "video" | "file";
text?: string;
mediaUrl?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
context?: {
conversationId?: string;
threadId?: string;
replyToMessageId?: string;
};
status: "success" | "error" | "processing";
error?: {
code: string;
message: string;
};
}
export interface A2AJsonRpcResponse {
jsonrpc: "2.0";
id: string;
result?: A2ATaskArtifactUpdateEvent | A2ATaskStatusUpdateEvent | A2AClearContextResult | A2ATasksCancelResult;
error?: {
code: number | string;
message: string;
};
}
export interface A2ATaskArtifactUpdateEvent {
taskId: string;
kind: "artifact-update";
append?: boolean;
lastChunk?: boolean;
final: boolean;
artifact: {
artifactId: string;
parts: Array<{
kind: "text" | "file" | "data";
text?: string;
file?: {
name: string;
mimeType: string;
bytes?: string;
uri?: string;
};
data?: any;
}>;
};
}
export interface A2ATaskStatusUpdateEvent {
taskId: string;
kind: "status-update";
final: boolean;
status: {
message: {
role: "agent";
parts: Array<{
kind: "text";
text: string;
}>;
};
state: "submitted" | "working" | "input-required" | "completed" | "canceled" | "failed" | "unknown";
};
}
export interface A2AClearContextResult {
status: {
state: "cleared" | "failed" | "unknown";
};
}
export interface A2ATasksCancelResult {
id: string;
status: {
state: "canceled" | "failed" | "unknown";
};
}
export interface A2AWebSocketMessage {
type: "message" | "heartbeat" | "auth" | "error";
data: A2ARequestMessage | A2AResponseMessage | any;
}
export type OutboundMessageType = "clawd_bot_init" | "agent_response" | "heartbeat";
export interface OutboundWebSocketMessage {
msgType: OutboundMessageType;
agentId: string;
sessionId?: string;
taskId?: string;
msgDetail?: string;
}
export interface A2AClearMessage {
agentId: string;
sessionId: string;
id: string;
action: "clear";
timestamp: number;
}
export interface A2ATasksCancelMessage {
agentId: string;
sessionId: string;
id: string;
action?: "tasks/cancel";
method?: "tasks/cancel";
taskId?: string;
jsonrpc?: "2.0";
conversationId?: string;
timestamp?: number;
}
export interface XiaoYiChannelConfig {
enabled: boolean;
wsUrl?: string;
wsUrl1?: string;
wsUrl2?: string;
ak: string;
sk: string;
agentId: string;
enableStreaming?: boolean;
apiId?: string;
pushId?: string;
taskTimeoutMs?: number;
/**
* Session cleanup timeout in milliseconds
* When user clears context, old sessions are cleaned up after this timeout
* Default: 1 hour (60 * 60 * 1000)
*/
sessionCleanupTimeoutMs?: number;
}
export interface AuthCredentials {
ak: string;
sk: string;
timestamp: number;
signature: string;
}
export interface WebSocketConnectionState {
connected: boolean;
authenticated: boolean;
lastHeartbeat: number;
lastAppHeartbeat: number;
reconnectAttempts: number;
maxReconnectAttempts: number;
}
export declare const DEFAULT_WS_URL_1 = "wss://hag.cloud.huawei.com/openclaw/v1/ws/link";
export declare const DEFAULT_WS_URL_2 = "wss://116.63.174.231/openclaw/v1/ws/link";
export interface InternalWebSocketConfig {
wsUrl1: string;
wsUrl2: string;
agentId: string;
ak: string;
sk: string;
enableStreaming?: boolean;
sessionCleanupTimeoutMs?: number;
}
export type ServerId = 'server1' | 'server2';
export interface ServerConnectionState {
connected: boolean;
ready: boolean;
lastHeartbeat: number;
reconnectAttempts: number;
}
/**
* Session cleanup state for delayed cleanup
*/
export interface SessionCleanupState {
sessionId: string;
serverId: ServerId;
markedForCleanupAt: number;
cleanupTimeoutId?: NodeJS.Timeout;
reason: 'user_cleared' | 'timeout' | 'error';
accumulatedText?: string;
}
+8
View File
@@ -0,0 +1,8 @@
"use strict";
// A2A Message Structure Types
// Based on: https://developer.huawei.com/consumer/cn/doc/service/message-stream-0000002505761434
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_WS_URL_2 = exports.DEFAULT_WS_URL_1 = void 0;
// Dual server configuration
exports.DEFAULT_WS_URL_1 = "wss://hag.cloud.huawei.com/openclaw/v1/ws/link";
exports.DEFAULT_WS_URL_2 = "wss://116.63.174.231/openclaw/v1/ws/link";
+211
View File
@@ -0,0 +1,211 @@
import { EventEmitter } from "events";
import { A2AResponseMessage, WebSocketConnectionState, XiaoYiChannelConfig, ServerId, ServerConnectionState, SessionCleanupState } from "./types";
export declare class XiaoYiWebSocketManager extends EventEmitter {
private ws1;
private ws2;
private state1;
private state2;
private sessionServerMap;
private sessionCleanupStateMap;
private static readonly DEFAULT_CLEANUP_TIMEOUT_MS;
private auth;
private config;
private heartbeatTimeout1?;
private heartbeatTimeout2?;
private appHeartbeatInterval?;
private reconnectTimeout1?;
private reconnectTimeout2?;
private stableConnectionTimer1?;
private stableConnectionTimer2?;
private static readonly STABLE_CONNECTION_THRESHOLD;
private activeTasks;
constructor(config: XiaoYiChannelConfig);
/**
* Check if URL is wss + IP format (skip certificate verification)
*/
private isWssWithIp;
/**
* Resolve configuration with defaults and backward compatibility
*/
private resolveConfig;
/**
* Connect to both WebSocket servers
*/
connect(): Promise<void>;
/**
* Connect to server 1
*/
private connectToServer1;
/**
* Connect to server 2
*/
private connectToServer2;
/**
* Disconnect from all servers
*/
disconnect(): void;
/**
* Send init message to specific server
*/
private sendInitMessage;
/**
* Setup WebSocket event handlers for specific server
*/
private setupWebSocketHandlers;
/**
* Extract sessionId from message based on method type
* Different methods have sessionId in different locations:
* - message/stream: sessionId in params, fallback to top-level sessionId
* - tasks/cancel: sessionId at top level
* - clearContext: sessionId at top level
*/
private extractSessionId;
/**
* Handle incoming message from specific server
*/
private handleIncomingMessage;
/**
* Send A2A response message with automatic routing
*/
sendResponse(response: A2AResponseMessage, taskId: string, sessionId: string, isFinal?: boolean, append?: boolean): Promise<void>;
/**
* Send clear context response to specific server
*/
sendClearContextResponse(requestId: string, sessionId: string, success?: boolean, targetServer?: ServerId): Promise<void>;
/**
* Send status update (for intermediate status messages, e.g., timeout warnings)
* This uses "status-update" event type which keeps the conversation active
*/
sendStatusUpdate(taskId: string, sessionId: string, message: string, targetServer?: ServerId): Promise<void>;
/**
* Send PUSH message (主动推送) via HTTP API
*
* This is used when SubAgent completes execution and needs to push results to user
* independently of the original A2A request-response flow.
*
* Unlike sendResponse (which responds to a specific request via WebSocket), push messages are
* sent through HTTP API asynchronously.
*
* @param sessionId - User's session ID
* @param message - Message content to push
*
* Reference: 华为小艺推送消息 API
* TODO: 实现实际的推送消息发送逻辑
*/
sendPushMessage(sessionId: string, message: string): Promise<void>;
/**
* Send tasks cancel response to specific server
*/
sendTasksCancelResponse(requestId: string, sessionId: string, success?: boolean, targetServer?: ServerId): Promise<void>;
/**
* Handle clearContext method
*/
private handleClearContext;
/**
* Handle clear message (legacy format)
*/
private handleClearMessage;
/**
* Handle tasks/cancel message
*/
private handleTasksCancelMessage;
/**
* Convert A2AResponseMessage to JSON-RPC 2.0 format
*/
private convertToJsonRpcFormat;
/**
* Check if at least one server is ready
*/
isReady(): boolean;
/**
* Get combined connection state
*/
getState(): WebSocketConnectionState;
/**
* Get individual server states
*/
getServerStates(): {
server1: ServerConnectionState;
server2: ServerConnectionState;
};
/**
* Start protocol-level heartbeat for specific server
*/
private startProtocolHeartbeat;
/**
* Clear protocol heartbeat for specific server
*/
private clearProtocolHeartbeat;
/**
* Start application-level heartbeat (shared across both servers)
*/
private startAppHeartbeat;
/**
* Schedule reconnection for specific server
*/
private scheduleReconnect;
/**
* Clear all timers
*/
private clearTimers;
/**
* Schedule a connection stability check
* Only reset reconnect counter after connection has been stable for threshold time
*/
private scheduleStableConnectionCheck;
/**
* Clear the connection stability check timer
*/
private clearStableConnectionCheck;
/**
* Type guard for A2A request messages
* sessionId can be in params OR at top level (fallback)
*/
private isA2ARequestMessage;
/**
* Get active tasks
*/
getActiveTasks(): Map<string, any>;
/**
* Remove task from active tasks
*/
removeActiveTask(taskId: string): void;
/**
* Get server for a specific session
*/
getServerForSession(sessionId: string): ServerId | undefined;
/**
* Remove session mapping
*/
removeSession(sessionId: string): void;
/**
* Mark a session for delayed cleanup
* @param sessionId The session ID to mark for cleanup
* @param serverId The server ID associated with this session
* @param timeoutMs Timeout in milliseconds before forcing cleanup
*/
private markSessionForCleanup;
/**
* Force cleanup a session immediately
* @param sessionId The session ID to cleanup
*/
forceCleanupSession(sessionId: string): void;
/**
* Check if a session is pending cleanup
* @param sessionId The session ID to check
* @returns True if session is pending cleanup
*/
isSessionPendingCleanup(sessionId: string): boolean;
/**
* Get cleanup state for a session
* @param sessionId The session ID to check
* @returns Cleanup state if exists, undefined otherwise
*/
getSessionCleanupState(sessionId: string): SessionCleanupState | undefined;
/**
* Update accumulated text for a pending cleanup session
* @param sessionId The session ID
* @param text The accumulated text
*/
updateAccumulatedTextForCleanup(sessionId: string, text: string): void;
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
/**
* XiaoYi Media Handler - Downloads and saves media files locally
* Similar to clawdbot-feishu's media.ts approach
*/
type PluginRuntime = any;
export interface DownloadedMedia {
path: string;
contentType: string;
placeholder: string;
fileName?: string;
}
export interface MediaDownloadOptions {
maxBytes?: number;
timeoutMs?: number;
}
/**
* Check if a MIME type is an image
*/
export declare function isImageMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is a PDF
*/
export declare function isPdfMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is text-based
*/
export declare function isTextMimeType(mimeType: string | undefined): boolean;
/**
* Download and save media file to local disk
* This is the key function that follows clawdbot-feishu's approach
*/
export declare function downloadAndSaveMedia(runtime: PluginRuntime, uri: string, mimeType: string, fileName: string, options?: MediaDownloadOptions): Promise<DownloadedMedia>;
/**
* Download and save multiple media files
*/
export declare function downloadAndSaveMediaList(runtime: PluginRuntime, files: Array<{
uri: string;
mimeType: string;
name: string;
}>, options?: MediaDownloadOptions): Promise<DownloadedMedia[]>;
/**
* Build media payload for inbound context
* Similar to clawdbot-feishu's buildFeishuMediaPayload()
*/
export declare function buildXiaoYiMediaPayload(mediaList: DownloadedMedia[]): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
};
/**
* Extract text from downloaded file for including in message body
*/
export declare function extractTextFromFile(path: string, mimeType: string): Promise<string | null>;
/**
* Input image content type for AI processing
*/
export interface InputImageContent {
type: "image";
data: string;
mimeType: string;
}
/**
* Image download limits
*/
export interface ImageLimits {
maxBytes?: number;
timeoutMs?: number;
}
/**
* Extract image from URL and return base64 encoded data
*/
export declare function extractImageFromUrl(url: string, limits?: Partial<ImageLimits>): Promise<InputImageContent>;
/**
* Extract text content from URL
* Supports text-based files (txt, md, json, xml, csv, etc.)
*/
export declare function extractTextFromUrl(url: string, maxBytes?: number, timeoutMs?: number): Promise<string>;
export {};
+216
View File
@@ -0,0 +1,216 @@
"use strict";
/**
* XiaoYi Media Handler - Downloads and saves media files locally
* Similar to clawdbot-feishu's media.ts approach
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isImageMimeType = isImageMimeType;
exports.isPdfMimeType = isPdfMimeType;
exports.isTextMimeType = isTextMimeType;
exports.downloadAndSaveMedia = downloadAndSaveMedia;
exports.downloadAndSaveMediaList = downloadAndSaveMediaList;
exports.buildXiaoYiMediaPayload = buildXiaoYiMediaPayload;
exports.extractTextFromFile = extractTextFromFile;
exports.extractImageFromUrl = extractImageFromUrl;
exports.extractTextFromUrl = extractTextFromUrl;
/**
* Download content from URL with validation
*/
async function fetchFromUrl(url, maxBytes, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "XiaoYi-Channel/1.0" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check content-length header if available
const contentLength = response.headers.get("content-length");
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size > maxBytes) {
throw new Error(`File too large: ${size} bytes (limit: ${maxBytes})`);
}
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > maxBytes) {
throw new Error(`File too large: ${buffer.byteLength} bytes (limit: ${maxBytes})`);
}
// Detect MIME type
const contentType = response.headers.get("content-type");
const mimeType = contentType?.split(";")[0]?.trim() || "application/octet-stream";
return { buffer, mimeType };
}
finally {
clearTimeout(timeout);
}
}
/**
* Infer placeholder text based on MIME type
*/
function inferPlaceholder(mimeType) {
if (mimeType.startsWith("image/")) {
return "<media:image>";
}
else if (mimeType.startsWith("video/")) {
return "<media:video>";
}
else if (mimeType.startsWith("audio/")) {
return "<media:audio>";
}
else if (mimeType === "application/pdf") {
return "<media:document>";
}
else if (mimeType.startsWith("text/")) {
return "<media:text>";
}
else {
return "<media:document>";
}
}
/**
* Check if a MIME type is an image
*/
function isImageMimeType(mimeType) {
if (!mimeType)
return false;
const lower = mimeType.toLowerCase();
// Standard formats: image/jpeg, image/png, etc.
if (lower.startsWith("image/")) {
return true;
}
// Handle non-standard formats like "jpeg" instead of "image/jpeg"
// Extract subtype if format is "type/subtype", otherwise use whole string
const subtype = lower.includes("/") ? lower.split("/")[1] : lower;
const imageSubtypes = [
"jpeg", "jpg", "png", "gif", "webp", "bmp", "svg+xml", "svg"
];
return imageSubtypes.includes(subtype);
}
/**
* Check if a MIME type is a PDF
*/
function isPdfMimeType(mimeType) {
return mimeType?.toLowerCase() === "application/pdf" || false;
}
/**
* Check if a MIME type is text-based
*/
function isTextMimeType(mimeType) {
if (!mimeType)
return false;
const lower = mimeType.toLowerCase();
return (lower.startsWith("text/") ||
lower === "application/json" ||
lower === "application/xml");
}
/**
* Download and save media file to local disk
* This is the key function that follows clawdbot-feishu's approach
*/
async function downloadAndSaveMedia(runtime, uri, mimeType, fileName, options) {
const maxBytes = options?.maxBytes ?? 30000000; // 30MB default
const timeoutMs = options?.timeoutMs ?? 60000; // 60 seconds default
console.log(`[XiaoYi Media] Downloading: ${fileName} (${mimeType}) from ${uri}`);
// Download the file
const { buffer, mimeType: detectedMimeType } = await fetchFromUrl(uri, maxBytes, timeoutMs);
// Use detected MIME type if provided type is generic
const finalMimeType = mimeType === "application/octet-stream" ? detectedMimeType : mimeType;
// Save to local disk using OpenClaw's core.media API
// This is the critical step - saves file locally and returns path
const saved = await runtime.channel.media.saveMediaBuffer(buffer, finalMimeType, "inbound", maxBytes, fileName);
console.log(`[XiaoYi Media] Saved to: ${saved.path}`);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(saved.contentType),
fileName,
};
}
/**
* Download and save multiple media files
*/
async function downloadAndSaveMediaList(runtime, files, options) {
const results = [];
for (const file of files) {
try {
const downloaded = await downloadAndSaveMedia(runtime, file.uri, file.mimeType, file.name, options);
results.push(downloaded);
}
catch (error) {
console.error(`[XiaoYi Media] Failed to download ${file.name}:`, error);
// Continue with other files
}
}
return results;
}
/**
* Build media payload for inbound context
* Similar to clawdbot-feishu's buildFeishuMediaPayload()
*/
function buildXiaoYiMediaPayload(mediaList) {
if (mediaList.length === 0) {
return {};
}
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean);
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
/**
* Extract text from downloaded file for including in message body
*/
async function extractTextFromFile(path, mimeType) {
// For now, just return null - Agent can read file directly from path
// This could be enhanced to extract text from specific file types
return null;
}
/**
* Extract image from URL and return base64 encoded data
*/
async function extractImageFromUrl(url, limits) {
const maxBytes = limits?.maxBytes ?? 10000000; // 10MB default
const timeoutMs = limits?.timeoutMs ?? 30000; // 30 seconds default
const { buffer, mimeType } = await fetchFromUrl(url, maxBytes, timeoutMs);
// Validate it's an image MIME type
if (!isImageMimeType(mimeType)) {
throw new Error(`Unsupported image type: ${mimeType}`);
}
return {
type: "image",
data: buffer.toString("base64"),
mimeType,
};
}
/**
* Extract text content from URL
* Supports text-based files (txt, md, json, xml, csv, etc.)
*/
async function extractTextFromUrl(url, maxBytes = 5000000, timeoutMs = 30000) {
const { buffer, mimeType } = await fetchFromUrl(url, maxBytes, timeoutMs);
// Check if it's a text-based MIME type
const textMimes = [
"text/plain",
"text/markdown",
"text/html",
"text/csv",
"application/json",
"application/xml",
"text/xml",
];
const isTextFile = textMimes.some(tm => mimeType.startsWith(tm) || mimeType === tm);
if (!isTextFile) {
throw new Error(`Unsupported text type: ${mimeType}`);
}
return buffer.toString("utf-8");
}
@@ -0,0 +1,9 @@
{
"id": "xiaoyi",
"channels": ["xiaoyi"],
"configSchema": {
"type": "object",
"additionalProperties": true,
"properties": {}
}
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@ynhcj/xiaoyi",
"version": "2.5.6",
"description": "XiaoYi channel plugin for OpenClaw",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"keywords": [
"openclaw",
"openclaw-plugin",
"xiaoyi",
"channel"
],
"author": "ynhcj",
"license": "MIT",
"openclaw": {
"extensions": ["xiaoyi.js"],
"channels": ["xiaoyi"],
"installDependencies": true,
"install": {
"npmSpec": "@ynhcj/xiaoyi@latest",
"localPath": ".",
"defaultChoice": "npm"
},
"channel": {
"id": "xiaoyi",
"label": "XiaoYi",
"selectionLabel": "XiaoYi (小艺)",
"docsPath": "/channels/xiaoyi",
"docsLabel": "xiaoyi",
"blurb": "小艺 A2A 协议支持,通过 WebSocket 连接。",
"order": 80,
"aliases": ["xy"]
}
},
"peerDependencies": {
"openclaw": "*"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"dependencies": {
"ws": "^8.16.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/ws": "^8.5.10",
"openclaw": "^2026.2.24",
"typescript": "^5.3.3"
},
"files": [
"dist",
"xiaoyi.js",
"openclaw.plugin.json",
"README.md"
]
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('./dist/index.js');