� backup: 2026-03-24 04:00
This commit is contained in:
@@ -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}×tamp={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
@@ -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
@@ -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
@@ -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;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
+1026
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* XiaoYi onboarding adapter for CLI setup wizard.
|
||||
*/
|
||||
type ChannelOnboardingAdapter = any;
|
||||
export declare const xiaoyiOnboardingAdapter: ChannelOnboardingAdapter;
|
||||
export {};
|
||||
+167
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
+1032
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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": {}
|
||||
}
|
||||
}
|
||||
+8624
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/index.js');
|
||||
Reference in New Issue
Block a user