palz-connector 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "palz-connector",
3
+ "name": "Palz Connector Channel",
4
+ "version": "0.0.1",
5
+ "description": "Palz IM 接入 OpenClaw",
6
+ "channels": ["palz-connector"],
7
+ "configSchema": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "properties": {
11
+ "enabled": { "type": "boolean", "default": true }
12
+ }
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "palz-connector",
3
+ "version": "0.0.1",
4
+ "description": "Palz IM 接入 OpenClaw",
5
+ "main": "plugin.ts",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "echo 'No build needed - jiti loads TS at runtime'"
9
+ },
10
+ "dependencies": {
11
+ "ws": "^8.18.0"
12
+ },
13
+ "openclaw": {
14
+ "extensions": ["./plugin.ts"],
15
+ "channels": ["palz-connector"],
16
+ "installDependencies": true
17
+ }
18
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "enabled": true,
3
+ "streamUrl": "ws://14.103.148.99:9090/ws/bot",
4
+ "apiBaseUrl": "http://14.103.148.99:9090/api",
5
+ "sessionTimeout": 1800000
6
+ }
package/plugin.ts ADDED
@@ -0,0 +1,1152 @@
1
+ /**
2
+ * Palz Connector Channel Plugin for OpenClaw
3
+ *
4
+ * Palz IM 接入 OpenClaw。
5
+ * 通过 WebSocket 接收 IM 消息,通过 Gateway WebSocket 获取 AI 回复,再通过 REST API 发送回 IM。
6
+ */
7
+
8
+ import WebSocket from 'ws';
9
+ import crypto from 'crypto';
10
+ import fs, { readFileSync } from 'fs';
11
+ import path, { resolve, dirname } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+
15
+ // ============ 设备身份认证 (Ed25519) ============
16
+
17
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
18
+
19
+ function _derivePublicKeyRaw(publicKeyPem: string): Buffer {
20
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
21
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
22
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
23
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
24
+ }
25
+ return spki;
26
+ }
27
+
28
+ function _fingerprintPublicKey(publicKeyPem: string): string {
29
+ return crypto.createHash('sha256').update(_derivePublicKeyRaw(publicKeyPem)).digest('hex');
30
+ }
31
+
32
+ function _base64UrlEncode(buf: Buffer): string {
33
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
34
+ }
35
+
36
+ function _loadOrCreateDeviceIdentity(identityPath: string): { deviceId: string; publicKeyPem: string; privateKeyPem: string } {
37
+ try {
38
+ if (fs.existsSync(identityPath)) {
39
+ const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
40
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) {
41
+ return parsed;
42
+ }
43
+ }
44
+ } catch { /* regenerate */ }
45
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
46
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
47
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
48
+ const identity = {
49
+ version: 1,
50
+ deviceId: _fingerprintPublicKey(publicKeyPem),
51
+ publicKeyPem,
52
+ privateKeyPem,
53
+ createdAtMs: Date.now(),
54
+ };
55
+ fs.mkdirSync(path.dirname(identityPath), { recursive: true });
56
+ fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
57
+ return identity;
58
+ }
59
+
60
+ function _buildDeviceAuth(
61
+ identity: { deviceId: string; publicKeyPem: string; privateKeyPem: string },
62
+ params: { clientId: string; clientMode: string; role: string; scopes: string[]; token: string; nonce: string },
63
+ ) {
64
+ const signedAt = Date.now();
65
+ const payload = [
66
+ 'v2', identity.deviceId, params.clientId, params.clientMode,
67
+ params.role, params.scopes.join(','), String(signedAt),
68
+ params.token || '', params.nonce,
69
+ ].join('|');
70
+ const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
71
+ const signature = _base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey) as unknown as Buffer);
72
+ const publicKeyB64Url = _base64UrlEncode(_derivePublicKeyRaw(identity.publicKeyPem));
73
+ return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce: params.nonce };
74
+ }
75
+
76
+ // ============ 类型定义 ============
77
+
78
+ type TextContentPart = { type: 'text'; text: string };
79
+ type ImageUrlContentPart = { type: 'image_url'; image_url: { url: string; detail?: string } };
80
+ type ContentPart = TextContentPart | ImageUrlContentPart;
81
+ type OpenAIContent = string | ContentPart[];
82
+
83
+ interface GatewayAttachment {
84
+ type: 'image';
85
+ mimeType: string;
86
+ content: string;
87
+ }
88
+
89
+ function extractPlainText(content: OpenAIContent): string {
90
+ if (typeof content === 'string') return content;
91
+ if (Array.isArray(content)) {
92
+ return content
93
+ .filter((p): p is TextContentPart => p.type === 'text')
94
+ .map(p => p.text)
95
+ .join('');
96
+ }
97
+ return '';
98
+ }
99
+
100
+ async function resolveImageAttachment(url: string, log?: any): Promise<GatewayAttachment | null> {
101
+ const dataUrlMatch = url.match(/^data:(image\/[^;]+);base64,(.+)$/);
102
+ if (dataUrlMatch) {
103
+ return { type: 'image', mimeType: dataUrlMatch[1], content: dataUrlMatch[2] };
104
+ }
105
+
106
+ if (url.startsWith('http://') || url.startsWith('https://')) {
107
+ log?.info?.(`[Palz][Attachment] 正在下载图片: ${url.slice(0, 200)}`);
108
+ try {
109
+ const response = await fetch(url);
110
+ if (!response.ok) {
111
+ log?.error?.(`[Palz][Attachment] 图片下载失败: ${response.status}`);
112
+ return null;
113
+ }
114
+ const contentType = response.headers.get('content-type') || 'image/png';
115
+ const mimeType = contentType.split(';')[0].trim();
116
+ const buffer = Buffer.from(await response.arrayBuffer());
117
+ return { type: 'image', mimeType, content: buffer.toString('base64') };
118
+ } catch (err: any) {
119
+ log?.error?.(`[Palz][Attachment] 图片下载异常: ${err.message}`);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ log?.warn?.(`[Palz][Attachment] 不支持的图片 URL scheme: ${url.slice(0, 100)}`);
125
+ return null;
126
+ }
127
+
128
+ async function extractAttachments(content: OpenAIContent, log?: any): Promise<GatewayAttachment[]> {
129
+ if (typeof content === 'string' || !Array.isArray(content)) return [];
130
+
131
+ const attachments: GatewayAttachment[] = [];
132
+ for (const part of content) {
133
+ if (part.type === 'image_url' && part.image_url?.url) {
134
+ const attachment = await resolveImageAttachment(part.image_url.url, log);
135
+ if (attachment) attachments.push(attachment);
136
+ }
137
+ }
138
+ return attachments;
139
+ }
140
+
141
+ // ============ 常量 & 配置 ============
142
+
143
+ export const id = 'palz-connector';
144
+
145
+ const FALLBACK_ACCOUNT_ID = '__default__';
146
+
147
+ const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话'];
148
+
149
+ let runtime: any = null;
150
+ let pluginLogger: any = null;
151
+ let _imConfig: any = null;
152
+
153
+ function getPluginDir(): string {
154
+ try {
155
+ return dirname(fileURLToPath(import.meta.url));
156
+ } catch {
157
+ return __dirname;
158
+ }
159
+ }
160
+
161
+ function readGatewayToken(log?: any): string {
162
+ if (process.env.OPENCLAW_GATEWAY_TOKEN) {
163
+ log?.info?.('[Palz][Config] gatewayToken 从环境变量 OPENCLAW_GATEWAY_TOKEN 读取');
164
+ return process.env.OPENCLAW_GATEWAY_TOKEN;
165
+ }
166
+
167
+ log?.warn?.('[Palz][Config] gatewayToken 未配置(环境变量 OPENCLAW_GATEWAY_TOKEN 未设置)');
168
+ return '';
169
+ }
170
+
171
+ function loadIMConfig(log?: any, forceReload = false): any {
172
+ if (_imConfig && !forceReload) return _imConfig;
173
+
174
+ const configPath = resolve(getPluginDir(), 'palz-connector.config.json');
175
+ try {
176
+ _imConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
177
+ log?.info?.(`[Palz][Config] 配置文件已加载: ${configPath}`);
178
+ } catch (err: any) {
179
+ if (!_imConfig) {
180
+ log?.error?.(`[Palz][Config] 配置加载失败(${configPath}): ${err.message},使用默认配置`);
181
+ _imConfig = { enabled: false };
182
+ }
183
+ }
184
+ if (!process.env.botID) {
185
+ log?.error?.('[Palz][Config] 环境变量 botID 未设置,botId 将为空,插件将无法正常启动');
186
+ }
187
+ _imConfig.botId = process.env.botID || '';
188
+ _imConfig.gatewayToken = readGatewayToken(log);
189
+
190
+ const missing: string[] = [];
191
+ if (!_imConfig.botId) missing.push('botID(环境变量)');
192
+ if (!_imConfig.streamUrl) missing.push('streamUrl(配置文件)');
193
+ if (!_imConfig.apiBaseUrl) missing.push('apiBaseUrl(配置文件)');
194
+ if (missing.length > 0) {
195
+ log?.error?.(`[Palz][Config] 关键配置缺失: ${missing.join(', ')},插件将无法正常工作`);
196
+ }
197
+
198
+ return _imConfig;
199
+ }
200
+
201
+ function getRuntime(): any {
202
+ if (!runtime) throw new Error('Palz runtime not initialized');
203
+ return runtime;
204
+ }
205
+
206
+ // ============ Session 管理 ============
207
+
208
+ interface UserSession {
209
+ lastActivity: number;
210
+ sessionId: string;
211
+ }
212
+
213
+ const userSessions = new Map<string, UserSession>();
214
+
215
+ const processedMessages = new Map<string, number>();
216
+ const MESSAGE_DEDUP_TTL = 5 * 60 * 1000;
217
+
218
+ function cleanupProcessedMessages(): void {
219
+ const now = Date.now();
220
+ for (const [msgId, ts] of processedMessages) {
221
+ if (now - ts > MESSAGE_DEDUP_TTL) processedMessages.delete(msgId);
222
+ }
223
+ }
224
+
225
+ function dedup(msgId: string): boolean {
226
+ if (!msgId) return false;
227
+ if (processedMessages.has(msgId)) return true;
228
+ processedMessages.set(msgId, Date.now());
229
+ if (processedMessages.size >= 100) cleanupProcessedMessages();
230
+ return false;
231
+ }
232
+
233
+ const userLocks = new Map<string, Promise<void>>();
234
+
235
+ function withUserLock(senderId: string, fn: () => Promise<void>): void {
236
+ const prev = userLocks.get(senderId) ?? Promise.resolve();
237
+ const next = prev.then(fn, fn);
238
+ userLocks.set(senderId, next);
239
+ next.finally(() => {
240
+ if (userLocks.get(senderId) === next) userLocks.delete(senderId);
241
+ });
242
+ }
243
+
244
+ function isNewSessionCommand(text: string): boolean {
245
+ const trimmed = text.trim().toLowerCase();
246
+ return NEW_SESSION_COMMANDS.some(cmd => trimmed === cmd.toLowerCase());
247
+ }
248
+
249
+ function getSessionKey(
250
+ senderId: string,
251
+ conversationId: string,
252
+ forceNew: boolean,
253
+ log?: any,
254
+ ): { sessionKey: string; isNew: boolean } {
255
+ const sessionId = `palz:${senderId}:${conversationId}`;
256
+ const now = Date.now();
257
+
258
+ if (forceNew) {
259
+ userSessions.set(senderId, { lastActivity: now, sessionId });
260
+ log?.info?.(`[Palz][Session] 用户主动开启新会话: ${senderId}, conversation=${conversationId}`);
261
+ return { sessionKey: sessionId, isNew: true };
262
+ }
263
+
264
+ const existing = userSessions.get(senderId);
265
+ if (existing && existing.sessionId === sessionId) {
266
+ existing.lastActivity = now;
267
+ return { sessionKey: sessionId, isNew: false };
268
+ }
269
+
270
+ userSessions.set(senderId, { lastActivity: now, sessionId });
271
+ log?.info?.(`[Palz][Session] 新会话: ${senderId}, conversation=${conversationId}`);
272
+ return { sessionKey: sessionId, isNew: !existing };
273
+ }
274
+
275
+ // ============ Gateway WebSocket 客户端 ============
276
+
277
+ const GW_WS_TIMEOUT = 120_000;
278
+
279
+ interface GwWsStreamHandler {
280
+ reqId: string;
281
+ runId: string | null;
282
+ onChunk: (text: string) => void;
283
+ onDone: () => void;
284
+ onError: (err: Error) => void;
285
+ }
286
+
287
+ let gwWs: WebSocket | null = null;
288
+ let gwWsReady = false;
289
+ let gwWsConnecting = false;
290
+ let gwWsReqSeq = 0;
291
+ const gwWsActiveStreams = new Map<string, GwWsStreamHandler>();
292
+ let gwWsPingInterval: ReturnType<typeof setInterval> | null = null;
293
+ let gwWsClosed = false;
294
+
295
+ function connectToGatewayWs(gatewayAuth: string, log?: any): Promise<void> {
296
+ if (gwWsReady) return Promise.resolve();
297
+ if (gwWsConnecting) {
298
+ return new Promise<void>((resolve, reject) => {
299
+ const check = setInterval(() => {
300
+ if (gwWsReady) { clearInterval(check); resolve(); }
301
+ else if (!gwWsConnecting) { clearInterval(check); reject(new Error('Gateway WS 连接失败')); }
302
+ }, 100);
303
+ setTimeout(() => { clearInterval(check); reject(new Error('等待 Gateway WS 连接超时')); }, 20_000);
304
+ });
305
+ }
306
+ gwWsConnecting = true;
307
+ gwWsClosed = false;
308
+
309
+ const rt = getRuntime();
310
+ const port = rt.gateway?.port || 18789;
311
+ const wsUrl = `ws://127.0.0.1:${port}`;
312
+
313
+ return new Promise<void>((resolveConnect, rejectConnect) => {
314
+ log?.info?.(`[Palz][GwWS] 正在连接 Gateway WebSocket: ${wsUrl}`);
315
+ const ws = new WebSocket(wsUrl);
316
+ gwWs = ws;
317
+
318
+ const connectTimeout = setTimeout(() => {
319
+ gwWsConnecting = false;
320
+ ws.close();
321
+ rejectConnect(new Error('Gateway WS 连接超时'));
322
+ }, 15_000);
323
+
324
+ ws.on('open', () => {
325
+ log?.info?.('[Palz][GwWS] WebSocket 已连接,等待 challenge...');
326
+ });
327
+
328
+ ws.on('message', (data: Buffer) => {
329
+ let frame: any;
330
+ try {
331
+ frame = JSON.parse(data.toString());
332
+ } catch {
333
+ log?.warn?.(`[Palz][GwWS] 无法解析帧: ${data.toString().slice(0, 200)}`);
334
+ return;
335
+ }
336
+
337
+ // 精简日志:streaming delta 只打 debug,其他事件打 info 摘要
338
+ if (frame.type === 'event' && frame.event === 'agent' && frame.payload?.stream === 'assistant') {
339
+ log?.debug?.(`[Palz][GwWS] <<< chunk delta, runId=${frame.payload?.runId}, len=${(frame.payload?.data?.delta || '').length}`);
340
+ } else if (frame.type === 'event' && frame.event === 'tick') {
341
+ // tick 心跳包静默
342
+ } else {
343
+ log?.info?.(`[Palz][GwWS] <<< ${frame.type}/${frame.event || frame.method || 'res'}: ${JSON.stringify(frame.payload || frame.error || {}).slice(0, 300)}`);
344
+ }
345
+
346
+ if (frame.type === 'event' && frame.event === 'connect.challenge') {
347
+ const nonce = frame.payload?.nonce ?? '';
348
+ log?.info?.(`[Palz][GwWS] 收到 challenge, nonce=${nonce.slice(0, 16)}...`);
349
+
350
+ const identityPath = resolve(getPluginDir(), 'device-identity.json');
351
+ const identity = _loadOrCreateDeviceIdentity(identityPath);
352
+ log?.info?.(`[Palz][GwWS] 设备身份: deviceId=${identity.deviceId.slice(0, 16)}...`);
353
+
354
+ const clientId = 'gateway-client';
355
+ const clientMode = 'backend';
356
+ const role = 'operator';
357
+ const scopes = ['operator.read', 'operator.write', 'operator.admin'];
358
+
359
+ const device = _buildDeviceAuth(identity, {
360
+ clientId, clientMode, role, scopes, token: gatewayAuth, nonce,
361
+ });
362
+
363
+ const connectReq = {
364
+ type: 'req',
365
+ id: `connect_${Date.now()}`,
366
+ method: 'connect',
367
+ params: {
368
+ minProtocol: 3,
369
+ maxProtocol: 3,
370
+ client: { id: clientId, version: '1.0.0', platform: process.platform, mode: clientMode },
371
+ role,
372
+ scopes,
373
+ device,
374
+ caps: ['tool-events'],
375
+ auth: { token: gatewayAuth },
376
+ },
377
+ };
378
+ log?.info?.('[Palz][GwWS] 发送 connect 请求 (device auth)');
379
+ ws.send(JSON.stringify(connectReq));
380
+ return;
381
+ }
382
+
383
+ if (frame.type === 'res') {
384
+ if (frame.payload?.type === 'hello-ok') {
385
+ clearTimeout(connectTimeout);
386
+ gwWsReady = true;
387
+ gwWsConnecting = false;
388
+ log?.info?.(`[Palz][GwWS] ✅ 握手成功, protocol=${frame.payload.protocol}`);
389
+
390
+ gwWsPingInterval = setInterval(() => {
391
+ if (gwWs?.readyState === WebSocket.OPEN) {
392
+ try { gwWs.ping(); } catch {}
393
+ }
394
+ }, 30_000);
395
+
396
+ resolveConnect();
397
+ return;
398
+ }
399
+
400
+ if (!frame.ok && !gwWsReady) {
401
+ clearTimeout(connectTimeout);
402
+ gwWsConnecting = false;
403
+ const errMsg = frame.error?.message || frame.error || JSON.stringify(frame);
404
+ log?.error?.(`[Palz][GwWS] ❌ 握手失败: ${errMsg}`);
405
+ ws.close();
406
+ rejectConnect(new Error(`Gateway WS 握手失败: ${errMsg}`));
407
+ return;
408
+ }
409
+
410
+ const handler = gwWsActiveStreams.get(frame.id);
411
+ if (handler) {
412
+ if (frame.ok) {
413
+ const runId = frame.payload?.runId;
414
+ if (runId) {
415
+ handler.runId = runId;
416
+ log?.info?.(`[Palz][GwWS] chat.send 已接受, reqId=${frame.id}, runId=${runId}`);
417
+ } else {
418
+ handler.onError(new Error('Gateway WS chat.send 响应缺少 runId'));
419
+ }
420
+ } else {
421
+ handler.onError(new Error(frame.error?.message || 'Gateway WS 请求失败'));
422
+ }
423
+ }
424
+ return;
425
+ }
426
+
427
+ if (frame.type === 'event' && gwWsReady) {
428
+ const payload = frame.payload;
429
+ const runId = payload?.runId;
430
+ if (!runId) return;
431
+
432
+ // 根据 runId 找到对应的 handler
433
+ let eventHandler: GwWsStreamHandler | null = null;
434
+ for (const h of gwWsActiveStreams.values()) {
435
+ if (h.runId && h.runId === runId) {
436
+ eventHandler = h;
437
+ break;
438
+ }
439
+ }
440
+ if (!eventHandler) return;
441
+
442
+ if (frame.event === 'agent' && payload?.stream === 'assistant') {
443
+ const delta = payload.data?.delta || '';
444
+ if (delta) {
445
+ eventHandler.onChunk(delta);
446
+ }
447
+ } else if (frame.event === 'agent' && payload?.stream === 'lifecycle') {
448
+ if (payload.data?.phase === 'end') {
449
+ log?.info?.(`[Palz][GwWS] lifecycle end, runId=${runId}`);
450
+ eventHandler.onDone();
451
+ }
452
+ }
453
+ }
454
+ });
455
+
456
+ ws.on('close', (code, reason) => {
457
+ const wasReady = gwWsReady;
458
+ gwWsReady = false;
459
+ gwWsConnecting = false;
460
+ gwWs = null;
461
+ clearTimeout(connectTimeout);
462
+ if (gwWsPingInterval) { clearInterval(gwWsPingInterval); gwWsPingInterval = null; }
463
+
464
+ for (const h of gwWsActiveStreams.values()) {
465
+ h.onError(new Error(`Gateway WS 连接断开 (code=${code})`));
466
+ }
467
+ gwWsActiveStreams.clear();
468
+
469
+ log?.warn?.(`[Palz][GwWS] 连接断开: code=${code}, reason=${reason?.toString()}, wasReady=${wasReady}`);
470
+ if (!wasReady) {
471
+ rejectConnect(new Error(`Gateway WS 连接断开: code=${code}`));
472
+ }
473
+ });
474
+
475
+ ws.on('error', (err) => {
476
+ log?.error?.(`[Palz][GwWS] 错误: ${err.message}`);
477
+ });
478
+ });
479
+ }
480
+
481
+ function contentToText(content: OpenAIContent): string {
482
+ if (typeof content === 'string') return content;
483
+ if (Array.isArray(content)) {
484
+ return content.map(part => {
485
+ if (part.type === 'text') return part.text;
486
+ if (part.type === 'image_url') return `![image](${part.image_url.url})`;
487
+ return '';
488
+ }).join('');
489
+ }
490
+ return '';
491
+ }
492
+
493
+ async function* streamFromGatewayWs(
494
+ userContent: OpenAIContent,
495
+ sessionKey: string,
496
+ gatewayAuth: string,
497
+ log?: any,
498
+ attachments?: GatewayAttachment[],
499
+ ): AsyncGenerator<string> {
500
+ if (!gwWsReady) {
501
+ await connectToGatewayWs(gatewayAuth, log);
502
+ }
503
+ if (!gwWs || !gwWsReady) {
504
+ throw new Error('Gateway WS 未就绪');
505
+ }
506
+
507
+ const reqId = `req_${++gwWsReqSeq}`;
508
+ const idempotencyKey = `idem-${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
509
+ const requestStartTime = Date.now();
510
+
511
+ type QueueItem = { type: 'chunk'; text: string } | { type: 'done' } | { type: 'error'; error: Error };
512
+ const queue: QueueItem[] = [];
513
+ let waiter: (() => void) | null = null;
514
+
515
+ function enqueue(item: QueueItem) {
516
+ queue.push(item);
517
+ if (waiter) {
518
+ const w = waiter;
519
+ waiter = null;
520
+ w();
521
+ }
522
+ }
523
+
524
+ function waitForItem(): Promise<void> {
525
+ if (queue.length > 0) return Promise.resolve();
526
+ return new Promise(resolve => { waiter = resolve; });
527
+ }
528
+
529
+ const timer = setTimeout(() => {
530
+ enqueue({ type: 'error', error: new Error(`Gateway WS 请求超时 (${GW_WS_TIMEOUT}ms)`) });
531
+ }, GW_WS_TIMEOUT);
532
+
533
+ gwWsActiveStreams.set(reqId, {
534
+ reqId,
535
+ runId: null,
536
+ onChunk: (text: string) => enqueue({ type: 'chunk', text }),
537
+ onDone: () => { clearTimeout(timer); enqueue({ type: 'done' }); },
538
+ onError: (err: Error) => { clearTimeout(timer); enqueue({ type: 'error', error: err }); },
539
+ });
540
+
541
+ const hasAttachments = attachments && attachments.length > 0;
542
+ const messageText = hasAttachments ? extractPlainText(userContent) : contentToText(userContent);
543
+ const params: Record<string, unknown> = {
544
+ sessionKey,
545
+ message: messageText,
546
+ deliver: false,
547
+ idempotencyKey,
548
+ };
549
+ if (hasAttachments) {
550
+ params.attachments = attachments;
551
+ }
552
+ const req = { type: 'req', id: reqId, method: 'chat.send', params };
553
+
554
+ log?.info?.(`[Palz][GwWS] 发送请求: reqId=${reqId}, session=${sessionKey}, attachments=${hasAttachments ? attachments!.length : 0}, message=${messageText.slice(0, 200)}`);
555
+ gwWs!.send(JSON.stringify(req));
556
+
557
+ let chunkCount = 0;
558
+ let fullResponse = '';
559
+ let firstChunkTime = 0;
560
+
561
+ try {
562
+ while (true) {
563
+ await waitForItem();
564
+ while (queue.length > 0) {
565
+ const item = queue.shift()!;
566
+ if (item.type === 'chunk') {
567
+ chunkCount++;
568
+ if (chunkCount === 1) {
569
+ firstChunkTime = Date.now();
570
+ log?.info?.(`[Palz][GwWS] 首个chunk, TTFC=${firstChunkTime - requestStartTime}ms`);
571
+ }
572
+ fullResponse += item.text;
573
+ if (chunkCount <= 3 || chunkCount % 20 === 0) {
574
+ log?.info?.(`[Palz][GwWS] chunk #${chunkCount}: "${item.text.slice(0, 100)}", 累积长度=${fullResponse.length}`);
575
+ }
576
+ yield item.text;
577
+ } else if (item.type === 'done') {
578
+ const totalElapsed = Date.now() - requestStartTime;
579
+ const ttfc = firstChunkTime ? firstChunkTime - requestStartTime : -1;
580
+ const tail = fullResponse.slice(-200);
581
+ log?.info?.(`[Palz][GwWS] ✅ 完成, reqId=${reqId}, chunks=${chunkCount}, len=${fullResponse.length}, TTFC=${ttfc}ms, total=${totalElapsed}ms, tail="${tail}"`);
582
+ return;
583
+ } else if (item.type === 'error') {
584
+ const totalElapsed = Date.now() - requestStartTime;
585
+ log?.error?.(`[Palz][GwWS] ❌ 错误, reqId=${reqId}, chunks=${chunkCount}, len=${fullResponse.length}, total=${totalElapsed}ms: ${item.error.message}`);
586
+ throw item.error;
587
+ }
588
+ }
589
+ }
590
+ } finally {
591
+ clearTimeout(timer);
592
+ gwWsActiveStreams.delete(reqId);
593
+ }
594
+ }
595
+
596
+ function closeGatewayWs(log?: any) {
597
+ gwWsClosed = true;
598
+ if (gwWsPingInterval) { clearInterval(gwWsPingInterval); gwWsPingInterval = null; }
599
+ for (const h of gwWsActiveStreams.values()) {
600
+ h.onError(new Error('Gateway WS 关闭'));
601
+ }
602
+ gwWsActiveStreams.clear();
603
+ if (gwWs) {
604
+ gwWs.removeAllListeners();
605
+ gwWs.close();
606
+ gwWs = null;
607
+ }
608
+ gwWsReady = false;
609
+ gwWsConnecting = false;
610
+ log?.info?.('[Palz][GwWS] Gateway WebSocket 已关闭');
611
+ }
612
+
613
+ // ============ IM 消息发送 ============
614
+
615
+ const STREAM_THROTTLE_MS = 200;
616
+
617
+ interface StreamOpts {
618
+ streamId: string;
619
+ seq: number;
620
+ isFinal: boolean;
621
+ delta: string;
622
+ }
623
+
624
+ let msgSeq = 0;
625
+
626
+ function nextMsgId(): string {
627
+ return `bot_reply_${Date.now()}_${++msgSeq}`;
628
+ }
629
+
630
+ async function sendToIM(
631
+ config: any,
632
+ conversationId: string,
633
+ content: OpenAIContent,
634
+ log?: any,
635
+ stream?: StreamOpts,
636
+ conversationType: string = 'direct',
637
+ msgId?: string,
638
+ senderId?: string,
639
+ ) {
640
+ const botId = config.botId;
641
+ const url = `${config.apiBaseUrl}/bot/send`;
642
+ const contentLength = typeof content === 'string' ? content.length : JSON.stringify(content).length;
643
+
644
+ const reqBody: Record<string, unknown> = {
645
+ bot_id: botId,
646
+ conversation_id: conversationId,
647
+ conversation_type: conversationType,
648
+ msg_id: msgId || nextMsgId(),
649
+ content,
650
+ };
651
+
652
+ if (senderId) {
653
+ reqBody.sender_id = senderId;
654
+ }
655
+
656
+ if (stream) {
657
+ reqBody.stream_id = stream.streamId;
658
+ reqBody.seq = stream.seq;
659
+ reqBody.is_final = stream.isFinal;
660
+ reqBody.delta = stream.delta;
661
+ }
662
+
663
+ log?.info?.(`[Palz][Send] POST ${url}, body=${JSON.stringify(reqBody)}`);
664
+
665
+ const sendStart = Date.now();
666
+ let response: Response;
667
+ try {
668
+ response = await fetch(url, {
669
+ method: 'POST',
670
+ headers: { 'Content-Type': 'application/json' },
671
+ body: JSON.stringify(reqBody),
672
+ });
673
+ } catch (err: any) {
674
+ const sendElapsed = Date.now() - sendStart;
675
+ log?.error?.(`[Palz][Send] 发送请求异常: ${err.message}, 耗时=${sendElapsed}ms, msg_id=${reqBody.msg_id}`);
676
+ throw err;
677
+ }
678
+
679
+ const sendElapsed = Date.now() - sendStart;
680
+ const rawResponseText = await response.text().catch(() => '');
681
+ let body: any = null;
682
+ try {
683
+ body = JSON.parse(rawResponseText);
684
+ } catch {}
685
+
686
+ log?.info?.(`[Palz][Send] 响应: status=${response.status}, 耗时=${sendElapsed}ms, body=${rawResponseText}`);
687
+ if (!response.ok) {
688
+ log?.error?.(`[Palz][Send] 发送失败: ${response.status} ${rawResponseText}, 耗时=${sendElapsed}ms`);
689
+ } else if (sendElapsed > 3000) {
690
+ log?.warn?.(`[Palz][Send] 耗时较长: ${sendElapsed}ms, msg_id=${reqBody.msg_id}`);
691
+ }
692
+
693
+ return body;
694
+ }
695
+
696
+ /**
697
+ * 流式回复:边从 Gateway 读取边向 IM 发送,带节流控制。
698
+ * 每个片段同时携带 content(完整累积)和 delta(本次增量),
699
+ * IM 端可按需选择:用 delta 追加渲染(高效),或用 content 全量替换(可靠)。
700
+ */
701
+ async function streamReplyToIM(
702
+ config: any,
703
+ conversationId: string,
704
+ gateway: AsyncGenerator<string>,
705
+ log?: any,
706
+ conversationType: string = 'direct',
707
+ msgId?: string,
708
+ senderId?: string,
709
+ ) {
710
+ const streamId = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
711
+ const streamStartTime = Date.now();
712
+ let fullResponse = '';
713
+ let lastSentLength = 0;
714
+ let lastSendTime = 0;
715
+ let seq = 0;
716
+ let gatewayChunkCount = 0;
717
+ let sendCount = 0;
718
+
719
+ log?.info?.(`[Palz][Stream] 开始流式回复, streamId=${streamId}, conversation=${conversationId}`);
720
+
721
+ try {
722
+ for await (const chunk of gateway) {
723
+ gatewayChunkCount++;
724
+ fullResponse += chunk;
725
+
726
+ const now = Date.now();
727
+ if (now - lastSendTime >= STREAM_THROTTLE_MS) {
728
+ const delta = fullResponse.slice(lastSentLength);
729
+ sendCount++;
730
+ await sendToIM(config, conversationId, fullResponse, log, {
731
+ streamId, seq: seq++, isFinal: false, delta,
732
+ }, conversationType, msgId, senderId);
733
+ lastSentLength = fullResponse.length;
734
+ lastSendTime = now;
735
+ }
736
+ }
737
+
738
+ const finalDelta = fullResponse.slice(lastSentLength);
739
+ sendCount++;
740
+ await sendToIM(config, conversationId, fullResponse || '(无响应)', log, {
741
+ streamId, seq: seq++, isFinal: true, delta: finalDelta,
742
+ }, conversationType, msgId, senderId);
743
+
744
+ const totalElapsed = Date.now() - streamStartTime;
745
+ log?.info?.(`[Palz][Stream] 流式回复完成, streamId=${streamId}, gatewayChunks=${gatewayChunkCount}, sends=${sendCount}, 响应长度=${fullResponse.length}, 总耗时=${totalElapsed}ms`);
746
+ } catch (err: any) {
747
+ const totalElapsed = Date.now() - streamStartTime;
748
+ log?.error?.(`[Palz][Stream] 流式回复异常: ${err.message}, streamId=${streamId}, gatewayChunks=${gatewayChunkCount}, sends=${sendCount}, 已积累长度=${fullResponse.length}, 已耗时=${totalElapsed}ms`);
749
+ if (fullResponse) {
750
+ const errSuffix = `\n\n[错误: ${err.message}]`;
751
+ await sendToIM(config, conversationId, fullResponse + errSuffix, log, {
752
+ streamId, seq: seq++, isFinal: true, delta: fullResponse.slice(lastSentLength) + errSuffix,
753
+ }, conversationType, msgId, senderId).catch(() => {});
754
+ }
755
+ throw err;
756
+ }
757
+ }
758
+
759
+ // ============ 核心消息处理 ============
760
+
761
+ function handleIMMessage(params: {
762
+ config: any;
763
+ msg: any;
764
+ log?: any;
765
+ }) {
766
+ const { config, msg, log } = params;
767
+ const content: OpenAIContent = msg.content;
768
+ if (!content) return;
769
+
770
+ const plainText = extractPlainText(content).trim();
771
+ const hasImages = Array.isArray(content) && content.some(p => p.type === 'image_url');
772
+ if (!plainText && !hasImages) return;
773
+
774
+ if (dedup(msg.msg_id)) {
775
+ log?.info?.(`[Palz][Session] 跳过重复消息: msg_id=${msg.msg_id}`);
776
+ return;
777
+ }
778
+
779
+ const senderId = msg.sender_id;
780
+ const conversationId = msg.conversation_id;
781
+ const conversationType = msg.conversation_type || 'direct';
782
+ const useStream = msg.stream === true;
783
+ const msgSnapshot = Object.fromEntries(
784
+ Object.entries(msg).map(([k, v]) => {
785
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
786
+ return [k, s.length > 300 ? s.slice(0, 300) + '...' : v];
787
+ }),
788
+ );
789
+ log?.info?.(`[Palz][Handle] 收到消息: ${JSON.stringify(msgSnapshot)}, stream=${useStream}`);
790
+
791
+ const msgReceivedTime = Date.now();
792
+
793
+ withUserLock(`${senderId}:${conversationId}`, async () => {
794
+ const lockAcquiredTime = Date.now();
795
+ const lockWait = lockAcquiredTime - msgReceivedTime;
796
+ if (lockWait > 100) {
797
+ log?.warn?.(`[Palz][Handle] 等待用户锁耗时: ${lockWait}ms, sender=${senderId}, msg_id=${msg.msg_id}`);
798
+ }
799
+ log?.info?.(`[Palz][Handle] 开始处理消息, msg_id=${msg.msg_id}, sender=${senderId}, 等锁=${lockWait}ms`);
800
+
801
+ if (isNewSessionCommand(plainText)) {
802
+ getSessionKey(senderId, conversationId, true, log);
803
+ await sendToIM(config, conversationId, '✨ 已开启新会话', log, undefined, conversationType, msg.msg_id, senderId);
804
+ log?.info?.(`[Palz][Handle] 新会话命令处理完成, 总耗时=${Date.now() - msgReceivedTime}ms`);
805
+ return;
806
+ }
807
+
808
+ const { sessionKey } = getSessionKey(senderId, conversationId, false, log);
809
+ const gatewayAuth = config.gatewayToken || '';
810
+
811
+ // 注入隐式上下文,让模型在使用 cron 工具时知道如何配置 delivery
812
+ const injectedContextText = `[系统隐式上下文: 当前所在渠道为 palz-connector,当前会话ID为 "${conversationId}",发送者ID为 "${senderId}"。注意:若需使用 cron 工具创建定时任务,请务必在 delivery 配置中指定 channel="palz-connector" 且 to="${senderId}:${conversationId}"]\n\n`;
813
+
814
+ let wrappedContent: OpenAIContent;
815
+ if (typeof content === 'string') {
816
+ wrappedContent = injectedContextText + content;
817
+ } else if (Array.isArray(content)) {
818
+ wrappedContent = [{ type: 'text', text: injectedContextText }, ...content];
819
+ } else {
820
+ wrappedContent = content;
821
+ }
822
+
823
+ try {
824
+ const imageAttachments = await extractAttachments(wrappedContent, log);
825
+ if (imageAttachments.length > 0) {
826
+ log?.info?.(`[Palz][Handle] 提取到 ${imageAttachments.length} 个图片附件`);
827
+ }
828
+
829
+ log?.info?.(`[Palz][Handle] 开始调用 Gateway WS, session=${sessionKey}, mode=${useStream ? 'stream' : 'non-stream'}, attachments=${imageAttachments.length}`);
830
+ const gatewayStartTime = Date.now();
831
+ const gateway = streamFromGatewayWs(wrappedContent, sessionKey, gatewayAuth, log, imageAttachments.length > 0 ? imageAttachments : undefined);
832
+ if (useStream) {
833
+ await streamReplyToIM(config, conversationId, gateway, log, conversationType, msg.msg_id, senderId);
834
+ } else {
835
+ let fullResponse = '';
836
+ let chunkCount = 0;
837
+ for await (const chunk of gateway) {
838
+ chunkCount++;
839
+ fullResponse += chunk;
840
+ }
841
+ const gatewayElapsed = Date.now() - gatewayStartTime;
842
+ log?.info?.(`[Palz][Handle] Gateway 非流式读取完成, chunks=${chunkCount}, 响应长度=${fullResponse.length}, 耗时=${gatewayElapsed}ms`);
843
+ await sendToIM(config, conversationId, fullResponse || '(无响应)', log, undefined, conversationType, msg.msg_id, senderId);
844
+ }
845
+
846
+ const totalElapsed = Date.now() - msgReceivedTime;
847
+ log?.info?.(`[Palz][Handle] ✅ 消息处理完成, msg_id=${msg.msg_id}, 总耗时=${totalElapsed}ms (等锁=${lockWait}ms)`);
848
+ } catch (err: any) {
849
+ const totalElapsed = Date.now() - msgReceivedTime;
850
+ log?.error?.(`[Palz][Handle] ❌ Gateway 调用失败: ${err.message}, msg_id=${msg.msg_id}, 已耗时=${totalElapsed}ms`);
851
+ await sendToIM(config, conversationId, `抱歉,处理请求时出错: ${err.message}`, log, undefined, conversationType, msg.msg_id, senderId);
852
+ }
853
+ });
854
+ }
855
+
856
+ // ============ IM WebSocket 客户端 ============
857
+
858
+ interface IMConnection {
859
+ close(): void;
860
+ }
861
+
862
+ function connectToIM(config: any, onMessage: (msg: any) => void, log?: any): IMConnection {
863
+ const botId = config.botId;
864
+ if (!botId) throw new Error('Palz botId is required');
865
+
866
+ const baseUrl = config.streamUrl.replace(/\/$/, '');
867
+ const separator = baseUrl.includes('?') ? '&' : '?';
868
+ const wsUrl = `${baseUrl}${separator}bot_id=${encodeURIComponent(botId)}`;
869
+ let reconnectDelay = 1000;
870
+ let closed = false;
871
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
872
+ let currentWs: WebSocket | null = null;
873
+ let consecutive4002 = 0;
874
+ const MAX_CONSECUTIVE_4002 = 5;
875
+ const REPLACED_RECONNECT_BASE_DELAY = 3000;
876
+
877
+ function connect() {
878
+ if (closed) return;
879
+ const ws = new WebSocket(wsUrl);
880
+ currentWs = ws;
881
+
882
+ let connectedAt = 0;
883
+
884
+ let pingInterval: ReturnType<typeof setInterval> | null = null;
885
+ let messageCount = 0;
886
+ let lastMessageTime = 0;
887
+
888
+ ws.on('open', () => {
889
+ log?.info?.(`[Palz][WS] 已连接, bot_id=${botId}, url=${wsUrl}`);
890
+ connectedAt = Date.now();
891
+ consecutive4002 = 0;
892
+
893
+ pingInterval = setInterval(() => {
894
+ if (ws.readyState === WebSocket.OPEN) {
895
+ const uptime = Math.round((Date.now() - connectedAt) / 1000);
896
+ const idleMs = lastMessageTime ? Date.now() - lastMessageTime : -1;
897
+ log?.info?.(`[Palz][WS] 心跳: 连接存活 ${uptime}s, 已收消息 ${messageCount} 条, 空闲 ${idleMs > 0 ? idleMs + 'ms' : 'N/A'}`);
898
+ try { ws.ping(); } catch {}
899
+ }
900
+ }, 60_000);
901
+ });
902
+
903
+ ws.on('pong', () => {
904
+ log?.info?.(`[Palz][WS] 收到 pong`);
905
+ });
906
+
907
+ ws.on('message', (data: Buffer) => {
908
+ log?.info?.(`[Palz][WS] <<< 收到WS包: ${data.toString().slice(0, 2000)}`);
909
+ try {
910
+ const msg = JSON.parse(data.toString());
911
+ if (msg.event !== 'message') {
912
+ log?.info?.(`[Palz][WS] 收到非 message 事件: event=${msg.event}, type=${msg.type || 'N/A'}`);
913
+ return;
914
+ }
915
+ messageCount++;
916
+ lastMessageTime = Date.now();
917
+ log?.info?.(`[Palz][WS] 收到第 ${messageCount} 条 message 事件, msg_id=${msg.msg_id || 'N/A'}`);
918
+ onMessage(msg);
919
+ } catch (err: any) {
920
+ log?.error?.(`[Palz][WS] 消息解析失败: ${err.message}, raw=${data.toString().slice(0, 500)}`);
921
+ }
922
+ });
923
+
924
+ ws.on('close', (code: number, reason: Buffer) => {
925
+ if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
926
+ if (closed) return;
927
+ const stableMs = Date.now() - connectedAt;
928
+ const reasonStr = reason?.toString() || '';
929
+
930
+ if (code === 4002) {
931
+ consecutive4002++;
932
+ if (consecutive4002 >= MAX_CONSECUTIVE_4002) {
933
+ log?.warn?.(`[Palz][WS] 连续 ${consecutive4002} 次被替代 (code=4002),放弃重连`);
934
+ closed = true;
935
+ return;
936
+ }
937
+ const jitter = Math.floor(Math.random() * 2000);
938
+ const delay = REPLACED_RECONNECT_BASE_DELAY * consecutive4002 + jitter;
939
+ log?.warn?.(`[Palz][WS] 被新连接替代 (code=${code}, reason=${reasonStr}, 第${consecutive4002}次),${delay}ms 后尝试重连`);
940
+ reconnectTimer = setTimeout(connect, delay);
941
+ return;
942
+ }
943
+
944
+ if (stableMs > 10_000) {
945
+ reconnectDelay = 1000;
946
+ }
947
+ log?.warn?.(`[Palz][WS] 断开 (code=${code}, reason=${reasonStr}, 连接维持${Math.round(stableMs / 1000)}s, 期间收到${messageCount}条消息),${reconnectDelay}ms 后重连`);
948
+ reconnectTimer = setTimeout(connect, reconnectDelay);
949
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
950
+ });
951
+
952
+ ws.on('error', (err) => {
953
+ log?.error?.(`[Palz][WS] 错误: ${err.message}, readyState=${ws.readyState}`);
954
+ });
955
+ }
956
+
957
+ connect();
958
+
959
+ return {
960
+ close() {
961
+ if (closed) return;
962
+ closed = true;
963
+ if (reconnectTimer) clearTimeout(reconnectTimer);
964
+ if (currentWs) {
965
+ currentWs.removeAllListeners();
966
+ currentWs.close();
967
+ currentWs = null;
968
+ }
969
+ },
970
+ };
971
+ }
972
+
973
+ // ============ 活跃连接追踪 ============
974
+
975
+ const activeConnections = new Map<string, IMConnection>();
976
+
977
+ // ============ 插件定义 ============
978
+
979
+ const palzPlugin = {
980
+ id: 'palz-connector',
981
+ meta: {
982
+ id: 'palz-connector',
983
+ label: 'Palz Connector',
984
+ selectionLabel: 'Palz Connector (IM)',
985
+ blurb: 'Palz IM 接入 OpenClaw',
986
+ },
987
+ capabilities: { chatTypes: ['direct'] },
988
+ config: {
989
+ listAccountIds: () => {
990
+ const imCfg = loadIMConfig(pluginLogger);
991
+ if (!imCfg?.enabled) {
992
+ pluginLogger?.warn?.('[Palz][Config] 插件未启用 (enabled=false),不会列出任何账号');
993
+ return [];
994
+ }
995
+ if (!imCfg.botId) {
996
+ pluginLogger?.error?.('[Palz][Config] botId 为空(环境变量 botID 未设置),无法列出账号');
997
+ return [];
998
+ }
999
+ return [imCfg.botId];
1000
+ },
1001
+ resolveAccount: (_cfg: any, accountId?: string) => {
1002
+ const imCfg = loadIMConfig();
1003
+ return {
1004
+ accountId: accountId || imCfg?.botId || FALLBACK_ACCOUNT_ID,
1005
+ config: imCfg,
1006
+ enabled: imCfg.enabled !== false,
1007
+ };
1008
+ },
1009
+ defaultAccountId: () => {
1010
+ const imCfg = loadIMConfig();
1011
+ return imCfg?.botId || FALLBACK_ACCOUNT_ID;
1012
+ },
1013
+ isConfigured: (account: any) => {
1014
+ const configured = Boolean(account.config?.botId && account.config?.streamUrl && account.config?.apiBaseUrl);
1015
+ if (!configured) {
1016
+ const missing: string[] = [];
1017
+ if (!account.config?.botId) missing.push('botId');
1018
+ if (!account.config?.streamUrl) missing.push('streamUrl');
1019
+ if (!account.config?.apiBaseUrl) missing.push('apiBaseUrl');
1020
+ pluginLogger?.error?.(`[Palz][Config] 账号配置不完整,缺少: ${missing.join(', ')},插件不会启动`);
1021
+ }
1022
+ return configured;
1023
+ },
1024
+ describeAccount: (account: any) => ({
1025
+ accountId: account.accountId,
1026
+ name: `Palz Connector (${account.config?.botId || 'unconfigured'})`,
1027
+ enabled: account.enabled,
1028
+ configured: Boolean(account.config?.botId && account.config?.streamUrl),
1029
+ }),
1030
+ },
1031
+ messaging: {
1032
+ normalizeTarget: (raw: string) => {
1033
+ (pluginLogger || console).info?.(`[Palz][Messaging] normalizeTarget called, raw="${raw}"`);
1034
+ if (!raw) return undefined;
1035
+ const result = raw.trim().replace(/^(palz-connector|palz):/i, '');
1036
+ (pluginLogger || console).info?.(`[Palz][Messaging] normalizeTarget result="${result}"`);
1037
+ return result;
1038
+ },
1039
+ targetResolver: {
1040
+ looksLikeId: (id: string) => {
1041
+ const result = /^[\w:._-]+$/.test(id);
1042
+ (pluginLogger || console).info?.(`[Palz][Messaging] looksLikeId called, id="${id}", result=${result}`);
1043
+ return result;
1044
+ },
1045
+ hint: '<senderId>:<conversationId>',
1046
+ },
1047
+ },
1048
+ outbound: {
1049
+ deliveryMode: 'direct' as const,
1050
+ sendText: async (ctx: any) => {
1051
+ const { to, text } = ctx;
1052
+ const log = ctx.log || pluginLogger || console;
1053
+ const startTime = Date.now();
1054
+ const parts = to.split(':');
1055
+ const senderId = parts.length >= 2 ? parts[0] : undefined;
1056
+ const conversationId = parts.length >= 2 ? parts.slice(1).join(':') : to;
1057
+ log.info?.(`[Palz][Outbound] 发送文本消息, to="${to}", senderId="${senderId}", conversationId="${conversationId}", length=${text?.length || 0}`);
1058
+ const config = loadIMConfig(log);
1059
+ await sendToIM(config, conversationId, text, log, undefined, 'direct', undefined, senderId);
1060
+ log.info?.(`[Palz][Outbound] 发送完成, to="${to}", 耗时=${Date.now() - startTime}ms`);
1061
+ return { channel: 'palz-connector', messageId: Date.now().toString() };
1062
+ },
1063
+ },
1064
+ gateway: {
1065
+ startAccount: async (ctx: any) => {
1066
+ const { account, abortSignal } = ctx;
1067
+ const config = account.config;
1068
+
1069
+ if (!config.botId || !config.streamUrl || !config.apiBaseUrl) {
1070
+ const missing: string[] = [];
1071
+ if (!config.botId) missing.push('botId(环境变量 botID)');
1072
+ if (!config.streamUrl) missing.push('streamUrl');
1073
+ if (!config.apiBaseUrl) missing.push('apiBaseUrl');
1074
+ const msg = `[Palz][Startup] 启动失败,关键配置缺失: ${missing.join(', ')}`;
1075
+ ctx.log?.error?.(msg);
1076
+ throw new Error(msg);
1077
+ }
1078
+
1079
+ ctx.log?.info?.(`[Palz][Startup] 启动客户端, account=${account.accountId}, botId=${config.botId}`);
1080
+
1081
+ const gatewayAuth = config.gatewayToken || '';
1082
+ try {
1083
+ await connectToGatewayWs(gatewayAuth, ctx.log);
1084
+ ctx.log?.info?.('[Palz][Startup] Gateway WebSocket 已连接');
1085
+ } catch (err: any) {
1086
+ ctx.log?.warn?.(`[Palz][Startup] Gateway WebSocket 预连接失败: ${err.message},将在首次请求时重试`);
1087
+ }
1088
+
1089
+ const connKey = config.botId;
1090
+ const oldConn = activeConnections.get(connKey);
1091
+ if (oldConn) {
1092
+ ctx.log?.info?.(`[Palz][Startup] 关闭旧的 IM WebSocket 连接, account=${account.accountId}`);
1093
+ oldConn.close();
1094
+ activeConnections.delete(connKey);
1095
+ }
1096
+
1097
+ const conn = connectToIM(config, (msg) => {
1098
+ handleIMMessage({ config, msg, log: ctx.log });
1099
+ }, ctx.log);
1100
+
1101
+ activeConnections.set(connKey, conn);
1102
+ ctx.log?.info?.(`[Palz][Startup] IM WebSocket 客户端已启动, account=${account.accountId}, 活跃连接数=${activeConnections.size}`);
1103
+
1104
+ return new Promise<void>((resolve) => {
1105
+ let stopped = false;
1106
+ function shutdown() {
1107
+ if (stopped) return;
1108
+ stopped = true;
1109
+ conn.close();
1110
+ closeGatewayWs(ctx.log);
1111
+ activeConnections.delete(connKey);
1112
+ ctx.log?.info?.(`[Palz][Startup] Channel 已停止, account=${account.accountId}, 剩余活跃连接数=${activeConnections.size}`);
1113
+ resolve();
1114
+ }
1115
+
1116
+ if (abortSignal) {
1117
+ if (abortSignal.aborted) {
1118
+ shutdown();
1119
+ return;
1120
+ }
1121
+ abortSignal.addEventListener('abort', shutdown);
1122
+ }
1123
+ });
1124
+ },
1125
+ },
1126
+ };
1127
+
1128
+ // ============ 插件注册 ============
1129
+
1130
+ let registered = false;
1131
+
1132
+ const plugin = {
1133
+ id: 'palz-connector',
1134
+ name: 'Palz Connector Channel',
1135
+ description: 'Palz IM 接入 OpenClaw',
1136
+ register(api: any) {
1137
+ runtime = api.runtime;
1138
+ const log = api.logger;
1139
+ pluginLogger = log;
1140
+
1141
+ log?.info?.(`[Palz][Startup] 开始注册插件... (已注册过=${registered})`);
1142
+
1143
+ const imCfg = loadIMConfig(log, true);
1144
+ log?.info?.(`[Palz][Startup] 配置状态: enabled=${imCfg.enabled}, botId=${imCfg.botId || '(空)'}, streamUrl=${imCfg.streamUrl || '(空)'}, apiBaseUrl=${imCfg.apiBaseUrl || '(空)'}, gatewayToken=${imCfg.gatewayToken ? '(已设置)' : '(空)'}`);
1145
+
1146
+ api.registerChannel({ plugin: palzPlugin });
1147
+ registered = true;
1148
+ log?.info?.('[Palz][Startup] 插件注册完成');
1149
+ },
1150
+ };
1151
+
1152
+ export default plugin;