palz-connector 1.0.0
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.
- package/openclaw.plugin.json +14 -0
- package/package.json +18 -0
- package/palz-connector.config.json +6 -0
- package/plugin.ts +512 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "palz-connector",
|
|
3
|
+
"name": "Palz Connector Channel",
|
|
4
|
+
"version": "1.0.0",
|
|
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": "1.0.0",
|
|
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
|
+
}
|
package/plugin.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palz Connector Channel Plugin for OpenClaw
|
|
3
|
+
*
|
|
4
|
+
* Palz IM 接入 OpenClaw。
|
|
5
|
+
* 通过 WebSocket 接收 IM 消息,调用 Gateway SSE 获取 AI 回复,再通过 REST API 发送回 IM。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import WebSocket from 'ws';
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { resolve, dirname, join } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
|
|
14
|
+
// ============ 类型定义 ============
|
|
15
|
+
|
|
16
|
+
type TextContentPart = { type: 'text'; text: string };
|
|
17
|
+
type ImageUrlContentPart = { type: 'image_url'; image_url: { url: string; detail?: string } };
|
|
18
|
+
type ContentPart = TextContentPart | ImageUrlContentPart;
|
|
19
|
+
type OpenAIContent = string | ContentPart[];
|
|
20
|
+
|
|
21
|
+
function extractPlainText(content: OpenAIContent): string {
|
|
22
|
+
if (typeof content === 'string') return content;
|
|
23
|
+
if (Array.isArray(content)) {
|
|
24
|
+
return content
|
|
25
|
+
.filter((p): p is TextContentPart => p.type === 'text')
|
|
26
|
+
.map(p => p.text)
|
|
27
|
+
.join('');
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============ 常量 & 配置 ============
|
|
33
|
+
|
|
34
|
+
export const id = 'palz-connector';
|
|
35
|
+
|
|
36
|
+
const FALLBACK_ACCOUNT_ID = '__default__';
|
|
37
|
+
|
|
38
|
+
const NEW_SESSION_COMMANDS = ['/new', '/reset', '/clear', '新会话', '重新开始', '清空对话'];
|
|
39
|
+
|
|
40
|
+
let runtime: any = null;
|
|
41
|
+
let _imConfig: any = null;
|
|
42
|
+
|
|
43
|
+
function getPluginDir(): string {
|
|
44
|
+
try {
|
|
45
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
} catch {
|
|
47
|
+
return __dirname;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readGatewayToken(log?: any): string {
|
|
52
|
+
try {
|
|
53
|
+
const openclawPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
54
|
+
const openclawConfig = JSON.parse(readFileSync(openclawPath, 'utf-8'));
|
|
55
|
+
const token = openclawConfig?.gateway?.auth?.token;
|
|
56
|
+
if (token) {
|
|
57
|
+
log?.info?.('[Palz] gatewayToken 从 ~/.openclaw/openclaw.json 读取');
|
|
58
|
+
return token;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// 文件不存在或解析失败,继续尝试环境变量
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (process.env.GATEWAY_TOKEN) {
|
|
65
|
+
return process.env.GATEWAY_TOKEN;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
log?.warn?.('[Palz] gatewayToken 未配置(~/.openclaw/openclaw.json 和环境变量 GATEWAY_TOKEN 均未设置)');
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadIMConfig(log?: any): any {
|
|
73
|
+
const configPath = resolve(getPluginDir(), 'palz-connector.config.json');
|
|
74
|
+
try {
|
|
75
|
+
_imConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
if (!_imConfig) {
|
|
78
|
+
log?.warn?.(`[Palz] 配置加载失败(${configPath}): ${err.message},使用默认配置`);
|
|
79
|
+
_imConfig = { enabled: false };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!process.env.BOT_ID) {
|
|
83
|
+
log?.warn?.('[Palz] 环境变量 BOT_ID 未设置,botId 将为空');
|
|
84
|
+
}
|
|
85
|
+
_imConfig.botId = process.env.BOT_ID || '';
|
|
86
|
+
_imConfig.gatewayToken = readGatewayToken(log);
|
|
87
|
+
return _imConfig;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getRuntime(): any {
|
|
91
|
+
if (!runtime) throw new Error('Palz runtime not initialized');
|
|
92
|
+
return runtime;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============ Session 管理 ============
|
|
96
|
+
|
|
97
|
+
interface UserSession {
|
|
98
|
+
lastActivity: number;
|
|
99
|
+
sessionId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const userSessions = new Map<string, UserSession>();
|
|
103
|
+
|
|
104
|
+
const processedMessages = new Map<string, number>();
|
|
105
|
+
const MESSAGE_DEDUP_TTL = 5 * 60 * 1000;
|
|
106
|
+
|
|
107
|
+
function cleanupProcessedMessages(): void {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
for (const [msgId, ts] of processedMessages) {
|
|
110
|
+
if (now - ts > MESSAGE_DEDUP_TTL) processedMessages.delete(msgId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dedup(msgId: string): boolean {
|
|
115
|
+
if (!msgId) return false;
|
|
116
|
+
if (processedMessages.has(msgId)) return true;
|
|
117
|
+
processedMessages.set(msgId, Date.now());
|
|
118
|
+
if (processedMessages.size >= 100) cleanupProcessedMessages();
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const userLocks = new Map<string, Promise<void>>();
|
|
123
|
+
|
|
124
|
+
function withUserLock(senderId: string, fn: () => Promise<void>): void {
|
|
125
|
+
const prev = userLocks.get(senderId) ?? Promise.resolve();
|
|
126
|
+
const next = prev.then(fn, fn);
|
|
127
|
+
userLocks.set(senderId, next);
|
|
128
|
+
next.finally(() => {
|
|
129
|
+
if (userLocks.get(senderId) === next) userLocks.delete(senderId);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isNewSessionCommand(text: string): boolean {
|
|
134
|
+
const trimmed = text.trim().toLowerCase();
|
|
135
|
+
return NEW_SESSION_COMMANDS.some(cmd => trimmed === cmd.toLowerCase());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getSessionKey(
|
|
139
|
+
senderId: string,
|
|
140
|
+
forceNew: boolean,
|
|
141
|
+
sessionTimeout: number,
|
|
142
|
+
log?: any,
|
|
143
|
+
): { sessionKey: string; isNew: boolean } {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
|
|
146
|
+
if (forceNew) {
|
|
147
|
+
const sessionId = `palz:${senderId}:${now}`;
|
|
148
|
+
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
149
|
+
log?.info?.(`[Palz][Session] 用户主动开启新会话: ${senderId}`);
|
|
150
|
+
return { sessionKey: sessionId, isNew: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const existing = userSessions.get(senderId);
|
|
154
|
+
if (existing) {
|
|
155
|
+
const elapsed = now - existing.lastActivity;
|
|
156
|
+
if (elapsed > sessionTimeout) {
|
|
157
|
+
const sessionId = `palz:${senderId}:${now}`;
|
|
158
|
+
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
159
|
+
log?.info?.(`[Palz][Session] 会话超时(${Math.round(elapsed / 60000)}分钟),自动开启新会话: ${senderId}`);
|
|
160
|
+
return { sessionKey: sessionId, isNew: true };
|
|
161
|
+
}
|
|
162
|
+
existing.lastActivity = now;
|
|
163
|
+
return { sessionKey: existing.sessionId, isNew: false };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sessionId = `palz:${senderId}:${now}`;
|
|
167
|
+
userSessions.set(senderId, { lastActivity: now, sessionId });
|
|
168
|
+
log?.info?.(`[Palz][Session] 新用户首次会话: ${senderId}`);
|
|
169
|
+
return { sessionKey: sessionId, isNew: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============ Gateway SSE 客户端 ============
|
|
173
|
+
|
|
174
|
+
const GATEWAY_TIMEOUT = 120_000;
|
|
175
|
+
|
|
176
|
+
async function* streamFromGateway(
|
|
177
|
+
userContent: OpenAIContent,
|
|
178
|
+
sessionKey: string,
|
|
179
|
+
gatewayAuth: string,
|
|
180
|
+
log?: any,
|
|
181
|
+
): AsyncGenerator<string> {
|
|
182
|
+
const rt = getRuntime();
|
|
183
|
+
const gatewayUrl = `http://127.0.0.1:${rt.gateway?.port || 18789}/v1/chat/completions`;
|
|
184
|
+
|
|
185
|
+
log?.info?.(`[Palz][Gateway] POST ${gatewayUrl}, session=${sessionKey}`);
|
|
186
|
+
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const timer = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT);
|
|
189
|
+
|
|
190
|
+
let response: Response;
|
|
191
|
+
try {
|
|
192
|
+
response = await fetch(gatewayUrl, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: {
|
|
195
|
+
'Content-Type': 'application/json',
|
|
196
|
+
...(gatewayAuth ? { Authorization: `Bearer ${gatewayAuth}` } : {}),
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
model: 'main',
|
|
200
|
+
messages: [{ role: 'user', content: userContent }],
|
|
201
|
+
stream: true,
|
|
202
|
+
user: sessionKey,
|
|
203
|
+
}),
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
});
|
|
206
|
+
} catch (err: any) {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!response.ok || !response.body) {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
throw new Error(`Gateway error: ${response.status}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const reader = (response.body as any).getReader();
|
|
217
|
+
const decoder = new TextDecoder();
|
|
218
|
+
let buffer = '';
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
while (true) {
|
|
222
|
+
const { done, value } = await reader.read();
|
|
223
|
+
if (done) break;
|
|
224
|
+
buffer += decoder.decode(value, { stream: true });
|
|
225
|
+
const lines = buffer.split('\n');
|
|
226
|
+
buffer = lines.pop() || '';
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
if (!line.startsWith('data: ')) continue;
|
|
229
|
+
const data = line.slice(6).trim();
|
|
230
|
+
if (data === '[DONE]') return;
|
|
231
|
+
try {
|
|
232
|
+
const chunk = JSON.parse(data);
|
|
233
|
+
const content = chunk.choices?.[0]?.delta?.content;
|
|
234
|
+
if (content) yield content;
|
|
235
|
+
} catch {
|
|
236
|
+
// skip malformed JSON chunks
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============ IM 消息发送 ============
|
|
246
|
+
|
|
247
|
+
async function sendToIM(config: any, conversationId: string, content: OpenAIContent, log?: any) {
|
|
248
|
+
const botId = config.botId;
|
|
249
|
+
const url = `${config.apiBaseUrl}/bot/send`;
|
|
250
|
+
const contentLength = typeof content === 'string' ? content.length : JSON.stringify(content).length;
|
|
251
|
+
log?.info?.(`[Palz][Send] POST ${url}, bot_id=${botId}, conversation=${conversationId}, length=${contentLength}`);
|
|
252
|
+
|
|
253
|
+
const response = await fetch(url, {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: { 'Content-Type': 'application/json' },
|
|
256
|
+
body: JSON.stringify({
|
|
257
|
+
bot_id: botId,
|
|
258
|
+
conversation_id: conversationId,
|
|
259
|
+
content,
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const body = await response.json().catch(() => null);
|
|
264
|
+
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
log?.error?.(`[Palz][Send] 发送消息失败: ${response.status} ${body?.error || ''}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return body;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============ 核心消息处理 ============
|
|
273
|
+
|
|
274
|
+
function handleIMMessage(params: {
|
|
275
|
+
config: any;
|
|
276
|
+
msg: any;
|
|
277
|
+
log?: any;
|
|
278
|
+
}) {
|
|
279
|
+
const { config, msg, log } = params;
|
|
280
|
+
const content: OpenAIContent = msg.content;
|
|
281
|
+
if (!content) return;
|
|
282
|
+
|
|
283
|
+
const plainText = extractPlainText(content).trim();
|
|
284
|
+
if (!plainText) return;
|
|
285
|
+
|
|
286
|
+
if (dedup(msg.msg_id)) {
|
|
287
|
+
log?.info?.(`[Palz] 跳过重复消息: ${msg.msg_id}`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const senderId = msg.sender_id;
|
|
292
|
+
const conversationId = msg.conversation_id;
|
|
293
|
+
log?.info?.(`[Palz] 收到消息: sender=${senderId}, bot_id=${config.botId}, conv=${conversationId}`);
|
|
294
|
+
|
|
295
|
+
withUserLock(senderId, async () => {
|
|
296
|
+
const sessionTimeout = config.sessionTimeout ?? 1800000;
|
|
297
|
+
|
|
298
|
+
if (isNewSessionCommand(plainText)) {
|
|
299
|
+
getSessionKey(senderId, true, sessionTimeout, log);
|
|
300
|
+
await sendToIM(config, conversationId, '✨ 已开启新会话', log);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { sessionKey } = getSessionKey(senderId, false, sessionTimeout, log);
|
|
305
|
+
const gatewayAuth = config.gatewayToken || '';
|
|
306
|
+
|
|
307
|
+
let fullResponse = '';
|
|
308
|
+
try {
|
|
309
|
+
for await (const chunk of streamFromGateway(content, sessionKey, gatewayAuth, log)) {
|
|
310
|
+
fullResponse += chunk;
|
|
311
|
+
}
|
|
312
|
+
await sendToIM(config, conversationId, fullResponse || '(无响应)', log);
|
|
313
|
+
} catch (err: any) {
|
|
314
|
+
log?.error?.(`[Palz][Gateway] 调用失败: ${err.message}`);
|
|
315
|
+
await sendToIM(config, conversationId, `抱歉,处理请求时出错: ${err.message}`, log);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ============ IM WebSocket 客户端 ============
|
|
321
|
+
|
|
322
|
+
interface IMConnection {
|
|
323
|
+
close(): void;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function connectToIM(config: any, onMessage: (msg: any) => void, log?: any): IMConnection {
|
|
327
|
+
const botId = config.botId;
|
|
328
|
+
if (!botId) throw new Error('Palz botId is required');
|
|
329
|
+
|
|
330
|
+
const baseUrl = config.streamUrl.replace(/\/$/, '');
|
|
331
|
+
const separator = baseUrl.includes('?') ? '&' : '?';
|
|
332
|
+
const wsUrl = `${baseUrl}${separator}bot_id=${encodeURIComponent(botId)}`;
|
|
333
|
+
let reconnectDelay = 1000;
|
|
334
|
+
let closed = false;
|
|
335
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
336
|
+
let currentWs: WebSocket | null = null;
|
|
337
|
+
|
|
338
|
+
function connect() {
|
|
339
|
+
if (closed) return;
|
|
340
|
+
const ws = new WebSocket(wsUrl);
|
|
341
|
+
currentWs = ws;
|
|
342
|
+
|
|
343
|
+
let connectedAt = 0;
|
|
344
|
+
|
|
345
|
+
ws.on('open', () => {
|
|
346
|
+
log?.info?.(`[Palz] WebSocket 已连接, bot_id=${botId}`);
|
|
347
|
+
connectedAt = Date.now();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
ws.on('message', (data: Buffer) => {
|
|
351
|
+
try {
|
|
352
|
+
const msg = JSON.parse(data.toString());
|
|
353
|
+
if (msg.event !== 'message') return;
|
|
354
|
+
onMessage(msg);
|
|
355
|
+
} catch (err: any) {
|
|
356
|
+
log?.error?.(`[Palz] 消息解析失败: ${err.message}`);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
ws.on('close', (code: number, reason: Buffer) => {
|
|
361
|
+
if (closed) return;
|
|
362
|
+
const stableMs = Date.now() - connectedAt;
|
|
363
|
+
const reasonStr = reason?.toString() || '';
|
|
364
|
+
|
|
365
|
+
if (code === 4002) {
|
|
366
|
+
log?.warn?.(`[Palz] WebSocket 被新连接替代 (code=${code}, reason=${reasonStr}),不再重连`);
|
|
367
|
+
closed = true;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (stableMs > 10_000) {
|
|
372
|
+
reconnectDelay = 1000;
|
|
373
|
+
}
|
|
374
|
+
log?.warn?.(`[Palz] WebSocket 断开 (code=${code}, reason=${reasonStr}, 连接维持${Math.round(stableMs / 1000)}s),${reconnectDelay}ms 后重连`);
|
|
375
|
+
reconnectTimer = setTimeout(connect, reconnectDelay);
|
|
376
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
ws.on('error', (err) => {
|
|
380
|
+
log?.error?.(`[Palz] WebSocket 错误: ${err.message}`);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
connect();
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
close() {
|
|
388
|
+
if (closed) return;
|
|
389
|
+
closed = true;
|
|
390
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
391
|
+
if (currentWs) {
|
|
392
|
+
currentWs.removeAllListeners();
|
|
393
|
+
currentWs.close();
|
|
394
|
+
currentWs = null;
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ============ 活跃连接追踪 ============
|
|
401
|
+
|
|
402
|
+
const activeConnections = new Map<string, IMConnection>();
|
|
403
|
+
|
|
404
|
+
// ============ 插件定义 ============
|
|
405
|
+
|
|
406
|
+
const palzPlugin = {
|
|
407
|
+
id: 'palz-connector',
|
|
408
|
+
meta: {
|
|
409
|
+
id: 'palz-connector',
|
|
410
|
+
label: 'Palz Connector',
|
|
411
|
+
selectionLabel: 'Palz Connector (IM)',
|
|
412
|
+
blurb: 'Palz IM 接入 OpenClaw',
|
|
413
|
+
},
|
|
414
|
+
capabilities: { chatTypes: ['direct'] },
|
|
415
|
+
config: {
|
|
416
|
+
listAccountIds: () => {
|
|
417
|
+
const imCfg = loadIMConfig();
|
|
418
|
+
if (!imCfg?.enabled) return [];
|
|
419
|
+
return [imCfg.botId || FALLBACK_ACCOUNT_ID];
|
|
420
|
+
},
|
|
421
|
+
resolveAccount: (_cfg: any, accountId?: string) => {
|
|
422
|
+
const imCfg = loadIMConfig();
|
|
423
|
+
return {
|
|
424
|
+
accountId: accountId || imCfg?.botId || FALLBACK_ACCOUNT_ID,
|
|
425
|
+
config: imCfg,
|
|
426
|
+
enabled: imCfg.enabled !== false,
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
defaultAccountId: () => {
|
|
430
|
+
const imCfg = loadIMConfig();
|
|
431
|
+
return imCfg?.botId || FALLBACK_ACCOUNT_ID;
|
|
432
|
+
},
|
|
433
|
+
isConfigured: (account: any) =>
|
|
434
|
+
Boolean(account.config?.botId && account.config?.streamUrl && account.config?.apiBaseUrl),
|
|
435
|
+
describeAccount: (account: any) => ({
|
|
436
|
+
accountId: account.accountId,
|
|
437
|
+
name: `Palz Connector (${account.config?.botId || 'unconfigured'})`,
|
|
438
|
+
enabled: account.enabled,
|
|
439
|
+
configured: Boolean(account.config?.botId && account.config?.streamUrl),
|
|
440
|
+
}),
|
|
441
|
+
},
|
|
442
|
+
outbound: {
|
|
443
|
+
deliveryMode: 'direct' as const,
|
|
444
|
+
sendText: async (ctx: any) => {
|
|
445
|
+
const config = loadIMConfig(ctx.log);
|
|
446
|
+
await sendToIM(config, ctx.to, ctx.text, ctx.log);
|
|
447
|
+
return { channel: 'palz-connector', messageId: Date.now().toString() };
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
gateway: {
|
|
451
|
+
startAccount: (ctx: any) => {
|
|
452
|
+
const { account, abortSignal } = ctx;
|
|
453
|
+
const config = account.config;
|
|
454
|
+
|
|
455
|
+
if (!config.botId || !config.streamUrl || !config.apiBaseUrl) {
|
|
456
|
+
throw new Error('Palz botId, streamUrl and apiBaseUrl are required');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
ctx.log?.info?.(`[${account.accountId}] 启动 Palz WebSocket 客户端...`);
|
|
460
|
+
|
|
461
|
+
const connKey = config.botId;
|
|
462
|
+
const oldConn = activeConnections.get(connKey);
|
|
463
|
+
if (oldConn) {
|
|
464
|
+
ctx.log?.info?.(`[${account.accountId}] 关闭旧的 WebSocket 连接...`);
|
|
465
|
+
oldConn.close();
|
|
466
|
+
activeConnections.delete(connKey);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const conn = connectToIM(config, (msg) => {
|
|
470
|
+
handleIMMessage({ config, msg, log: ctx.log });
|
|
471
|
+
}, ctx.log);
|
|
472
|
+
|
|
473
|
+
activeConnections.set(connKey, conn);
|
|
474
|
+
ctx.log?.info?.(`[${account.accountId}] Palz WebSocket 客户端已启动`);
|
|
475
|
+
|
|
476
|
+
return new Promise<void>((resolve) => {
|
|
477
|
+
let stopped = false;
|
|
478
|
+
function shutdown() {
|
|
479
|
+
if (stopped) return;
|
|
480
|
+
stopped = true;
|
|
481
|
+
conn.close();
|
|
482
|
+
activeConnections.delete(connKey);
|
|
483
|
+
ctx.log?.info?.(`[${account.accountId}] Palz Channel 已停止`);
|
|
484
|
+
resolve();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (abortSignal) {
|
|
488
|
+
if (abortSignal.aborted) {
|
|
489
|
+
shutdown();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
abortSignal.addEventListener('abort', shutdown);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// ============ 插件注册 ============
|
|
500
|
+
|
|
501
|
+
const plugin = {
|
|
502
|
+
id: 'palz-connector',
|
|
503
|
+
name: 'Palz Connector Channel',
|
|
504
|
+
description: 'Palz IM 接入 OpenClaw',
|
|
505
|
+
register(api: any) {
|
|
506
|
+
runtime = api.runtime;
|
|
507
|
+
api.registerChannel({ plugin: palzPlugin });
|
|
508
|
+
api.logger?.info('[Palz] 插件已注册');
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
export default plugin;
|