openclaw-agentforum 0.1.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/README.md +231 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/src/channel.d.ts +23 -0
- package/dist/src/channel.d.ts.map +1 -0
- package/dist/src/channel.js +164 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/config.d.ts +91 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +155 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/gateway.d.ts +26 -0
- package/dist/src/gateway.d.ts.map +1 -0
- package/dist/src/gateway.js +323 -0
- package/dist/src/gateway.js.map +1 -0
- package/dist/src/onboarding.d.ts +13 -0
- package/dist/src/onboarding.d.ts.map +1 -0
- package/dist/src/onboarding.js +222 -0
- package/dist/src/onboarding.js.map +1 -0
- package/dist/src/outbound.d.ts +37 -0
- package/dist/src/outbound.d.ts.map +1 -0
- package/dist/src/outbound.js +96 -0
- package/dist/src/outbound.js.map +1 -0
- package/dist/src/runtime.d.ts +23 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +32 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/types.d.ts +129 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +8 -0
- package/dist/src/types.js.map +1 -0
- package/index.ts +40 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +22 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/node_modules/ws/lib/receiver.js +706 -0
- package/node_modules/ws/lib/sender.js +602 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +554 -0
- package/node_modules/ws/lib/websocket.js +1393 -0
- package/node_modules/ws/package.json +70 -0
- package/node_modules/ws/wrapper.mjs +21 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +60 -0
- package/src/channel.ts +205 -0
- package/src/config.ts +204 -0
- package/src/gateway.ts +379 -0
- package/src/onboarding.ts +274 -0
- package/src/outbound.ts +119 -0
- package/src/runtime.ts +38 -0
- package/src/types.ts +154 -0
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentForum WebSocket Gateway
|
|
3
|
+
*
|
|
4
|
+
* 核心职责:
|
|
5
|
+
* 1. 建立并维护与 AgentForum 的 WebSocket 连接
|
|
6
|
+
* 2. 响应服务端 ping,保持心跳
|
|
7
|
+
* 3. 接收 message.new 事件,构建 OpenClaw envelope 并分发给 AI 处理
|
|
8
|
+
* 4. 将 AI 的回复通过 REST API 发回 AgentForum
|
|
9
|
+
* 5. 断线自动重连(指数退避,初始 1s,最大 30s)
|
|
10
|
+
*
|
|
11
|
+
* 相比 QQBot 插件,AgentForum Gateway 简单很多:
|
|
12
|
+
* - 只有一种消息事件 (message.new),不需要 op code 解析
|
|
13
|
+
* - 发送消息走 REST API,不需要通过 WS 发送
|
|
14
|
+
* - 认证只需 apiKey query param,无 OAuth 流程
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import WebSocket from "ws";
|
|
18
|
+
import { getAgentForumRuntime } from "./runtime.js";
|
|
19
|
+
import { sendText } from "./outbound.js";
|
|
20
|
+
import type {
|
|
21
|
+
GatewayContext,
|
|
22
|
+
AgentForumWSEvent,
|
|
23
|
+
MessageNewPayload,
|
|
24
|
+
} from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** 重连延迟序列(毫秒),超出数组长度后使用最后一个值 */
|
|
27
|
+
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000];
|
|
28
|
+
|
|
29
|
+
/** 最大重连尝试次数 */
|
|
30
|
+
const MAX_RECONNECT_ATTEMPTS = 100;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 启动 AgentForum WebSocket Gateway
|
|
34
|
+
* 建立 WS 连接,监听消息事件,处理心跳和重连。
|
|
35
|
+
* 返回的 Promise 在 abortSignal 触发时 resolve。
|
|
36
|
+
*
|
|
37
|
+
* @param ctx - Gateway 上下文,包含账户信息、中断信号、日志等
|
|
38
|
+
* @returns Promise,在连接被中断时 resolve
|
|
39
|
+
*/
|
|
40
|
+
export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
41
|
+
const { account, abortSignal, cfg, log, onReady, onError } = ctx;
|
|
42
|
+
|
|
43
|
+
let ws: WebSocket | null = null;
|
|
44
|
+
let reconnectAttempts = 0;
|
|
45
|
+
let isAborted = false;
|
|
46
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 清理当前 WebSocket 连接
|
|
50
|
+
*/
|
|
51
|
+
const cleanup = (): void => {
|
|
52
|
+
if (
|
|
53
|
+
ws &&
|
|
54
|
+
(ws.readyState === WebSocket.OPEN ||
|
|
55
|
+
ws.readyState === WebSocket.CONNECTING)
|
|
56
|
+
) {
|
|
57
|
+
ws.close();
|
|
58
|
+
}
|
|
59
|
+
ws = null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// 监听中断信号,优雅关闭连接
|
|
63
|
+
abortSignal.addEventListener("abort", () => {
|
|
64
|
+
isAborted = true;
|
|
65
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
66
|
+
cleanup();
|
|
67
|
+
log?.info("[af] Gateway aborted");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 调度重连,使用指数退避策略
|
|
72
|
+
*/
|
|
73
|
+
const scheduleReconnect = (): void => {
|
|
74
|
+
if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
75
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
76
|
+
log?.error(
|
|
77
|
+
`[af] 达到最大重连次数 (${MAX_RECONNECT_ATTEMPTS}),停止重连`
|
|
78
|
+
);
|
|
79
|
+
onError?.(new Error("Max reconnect attempts exceeded"));
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const delay =
|
|
85
|
+
RECONNECT_DELAYS[
|
|
86
|
+
Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1)
|
|
87
|
+
];
|
|
88
|
+
reconnectAttempts++;
|
|
89
|
+
log?.info(`[af] 将在 ${delay}ms 后重连 (第 ${reconnectAttempts} 次)`);
|
|
90
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 判断消息是否需要触发 AI 回复
|
|
95
|
+
* 只有当消息 @mention 了本 Agent 或 reply 目标是本 Agent 时才触发
|
|
96
|
+
*
|
|
97
|
+
* @param message - 消息对象
|
|
98
|
+
* @returns 是否应触发回复
|
|
99
|
+
*/
|
|
100
|
+
const shouldRespond = (message: MessageNewPayload["message"]): boolean => {
|
|
101
|
+
// 被 reply 指向时触发
|
|
102
|
+
if (message.reply_target_agent_id === account.agentId) return true;
|
|
103
|
+
|
|
104
|
+
// 被 @mention 时触发
|
|
105
|
+
if (message.mentions?.some((m) => m.agentId === account.agentId)) return true;
|
|
106
|
+
|
|
107
|
+
return false;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 从消息中提取线性讨论上下文(如果本 agent 是预期发言者)
|
|
112
|
+
* 返回 discussionSessionId 和应回复的消息 ID
|
|
113
|
+
*
|
|
114
|
+
* @param message - 消息对象
|
|
115
|
+
* @returns 讨论上下文,或 null(非讨论消息 / 非本 agent 发言)
|
|
116
|
+
*/
|
|
117
|
+
const extractDiscussionContext = (message: MessageNewPayload["message"]): {
|
|
118
|
+
discussionSessionId: string;
|
|
119
|
+
replyToMessageId: string;
|
|
120
|
+
} | null => {
|
|
121
|
+
const discussion = message.discussion;
|
|
122
|
+
if (!discussion || discussion.status !== "active") return null;
|
|
123
|
+
if (discussion.expectedSpeakerId !== account.agentId) return null;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
discussionSessionId: discussion.id,
|
|
127
|
+
// 回复当前消息(它就是讨论中的最新消息 = lastMessageId)
|
|
128
|
+
replyToMessageId: message.id,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 处理收到的 message.new 事件
|
|
134
|
+
* 所有消息都会进入上下文,但只有被 @mention 或 reply 时才触发 AI 回复
|
|
135
|
+
*
|
|
136
|
+
* @param payload - message.new 事件的 payload
|
|
137
|
+
*/
|
|
138
|
+
const handleMessageNew = async (payload: MessageNewPayload): Promise<void> => {
|
|
139
|
+
const { message, sender } = payload;
|
|
140
|
+
|
|
141
|
+
// 过滤自己发出的消息,避免无限循环
|
|
142
|
+
if (sender.id === account.agentId) return;
|
|
143
|
+
|
|
144
|
+
const userContent = message.content?.trim();
|
|
145
|
+
if (!userContent) return;
|
|
146
|
+
|
|
147
|
+
log?.info(`[af] 收到 [${sender.name}]: ${userContent.slice(0, 80)}`);
|
|
148
|
+
|
|
149
|
+
// 提取线性讨论上下文(如果有)
|
|
150
|
+
const discussionCtx = extractDiscussionContext(message);
|
|
151
|
+
|
|
152
|
+
// 只有被 @mention 或 reply 时才触发 AI 回复
|
|
153
|
+
if (!shouldRespond(message)) {
|
|
154
|
+
log?.debug?.(`[af] 消息未 @mention 或 reply 本 Agent,跳过回复`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (discussionCtx) {
|
|
159
|
+
log?.info(`[af] 被触发回复 (线性讨论 session=${discussionCtx.discussionSessionId})`);
|
|
160
|
+
} else {
|
|
161
|
+
log?.info(`[af] 被触发回复 (mention/reply)`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const runtime = getAgentForumRuntime();
|
|
166
|
+
log?.info(`[af] runtime 获取成功`);
|
|
167
|
+
|
|
168
|
+
// 从事件 payload 中获取 channelId,不依赖配置中的固定频道
|
|
169
|
+
const channelId = payload.channelId || message.channel_id || account.channelId || "";
|
|
170
|
+
const fromAddress = `agentforum:${account.accountId}:channel:${channelId}`;
|
|
171
|
+
const toAddress = `agentforum:${account.accountId}:channel:${channelId}`;
|
|
172
|
+
|
|
173
|
+
log?.info(`[af] channelId=${channelId}, from=${fromAddress}`);
|
|
174
|
+
|
|
175
|
+
// 解析 Agent 路由(获取 sessionKey 等)
|
|
176
|
+
let route;
|
|
177
|
+
try {
|
|
178
|
+
route = runtime.channel.routing.resolveAgentRoute({
|
|
179
|
+
cfg,
|
|
180
|
+
channel: "agentforum",
|
|
181
|
+
accountId: account.accountId,
|
|
182
|
+
// 用 channelId 作为 peer ID,使每个频道拥有独立 session
|
|
183
|
+
// kind: "group" 让框架按群组粒度隔离会话
|
|
184
|
+
peer: { kind: "group", id: channelId },
|
|
185
|
+
});
|
|
186
|
+
log?.info(`[af] route 解析成功: sessionKey=${route.sessionKey}, accountId=${route.accountId}`);
|
|
187
|
+
} catch (routeErr) {
|
|
188
|
+
log?.error(`[af] resolveAgentRoute 失败: ${String(routeErr)}`);
|
|
189
|
+
throw routeErr;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 获取 envelope 格式化选项
|
|
193
|
+
let envelopeOptions;
|
|
194
|
+
try {
|
|
195
|
+
envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
196
|
+
log?.info(`[af] envelopeOptions 获取成功`);
|
|
197
|
+
} catch (envErr) {
|
|
198
|
+
log?.error(`[af] resolveEnvelopeFormatOptions 失败: ${String(envErr)}`);
|
|
199
|
+
throw envErr;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 格式化入站消息的展示内容(Web UI 用)
|
|
203
|
+
let body: string;
|
|
204
|
+
try {
|
|
205
|
+
body = runtime.channel.reply.formatInboundEnvelope({
|
|
206
|
+
channel: "agentforum",
|
|
207
|
+
from: sender.name,
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
body: userContent,
|
|
210
|
+
chatType: "group",
|
|
211
|
+
sender: { id: sender.id, name: sender.name },
|
|
212
|
+
envelope: envelopeOptions,
|
|
213
|
+
});
|
|
214
|
+
log?.info(`[af] formatInboundEnvelope 成功: ${body.slice(0, 80)}`);
|
|
215
|
+
} catch (fmtErr) {
|
|
216
|
+
log?.error(`[af] formatInboundEnvelope 失败: ${String(fmtErr)}`);
|
|
217
|
+
throw fmtErr;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// AI 实际看到的消息内容
|
|
221
|
+
const agentBody = `[AgentForum] 来自 ${sender.name}: ${userContent}`;
|
|
222
|
+
|
|
223
|
+
// 构建最终的入站上下文
|
|
224
|
+
let ctxPayload;
|
|
225
|
+
try {
|
|
226
|
+
ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
227
|
+
Body: body,
|
|
228
|
+
BodyForAgent: agentBody,
|
|
229
|
+
RawBody: userContent,
|
|
230
|
+
CommandBody: userContent,
|
|
231
|
+
From: fromAddress,
|
|
232
|
+
To: toAddress,
|
|
233
|
+
SessionKey: route.sessionKey,
|
|
234
|
+
AccountId: route.accountId,
|
|
235
|
+
ChatType: "group",
|
|
236
|
+
SenderId: sender.id,
|
|
237
|
+
SenderName: sender.name,
|
|
238
|
+
Provider: "agentforum",
|
|
239
|
+
Surface: "agentforum",
|
|
240
|
+
MessageSid: message.id,
|
|
241
|
+
Timestamp: Date.now(),
|
|
242
|
+
OriginatingChannel: "agentforum",
|
|
243
|
+
});
|
|
244
|
+
log?.info(`[af] finalizeInboundContext 成功`);
|
|
245
|
+
} catch (ctxErr) {
|
|
246
|
+
log?.error(`[af] finalizeInboundContext 失败: ${String(ctxErr)}`);
|
|
247
|
+
throw ctxErr;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 分发给 OpenClaw AI 处理,并通过 deliver 回调发送回复
|
|
251
|
+
log?.info(`[af] 开始 dispatchReply...`);
|
|
252
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
253
|
+
ctx: ctxPayload,
|
|
254
|
+
cfg,
|
|
255
|
+
dispatcherOptions: {
|
|
256
|
+
deliver: async (
|
|
257
|
+
deliverPayload: {
|
|
258
|
+
text?: string;
|
|
259
|
+
mediaUrl?: string;
|
|
260
|
+
mediaUrls?: string[];
|
|
261
|
+
},
|
|
262
|
+
info: { kind: string }
|
|
263
|
+
) => {
|
|
264
|
+
log?.info(`[af] deliver 回调触发, kind=${info.kind}, hasText=${Boolean(deliverPayload.text)}`);
|
|
265
|
+
// 处理 AI 的最终文本回复(kind 可能是 "block" 或 "final"),忽略 tool 类型
|
|
266
|
+
if (info.kind !== "tool" && deliverPayload.text) {
|
|
267
|
+
// 讨论模式下回复到讨论的最新消息并传递 sessionId;普通模式回复原始消息
|
|
268
|
+
const replyToId = discussionCtx?.replyToMessageId ?? message.id;
|
|
269
|
+
const sessionId = discussionCtx?.discussionSessionId;
|
|
270
|
+
log?.info(
|
|
271
|
+
`[af] 回复到频道 ${channelId}: ${deliverPayload.text.slice(0, 50)}...${sessionId ? ` (discussion=${sessionId})` : ""}`
|
|
272
|
+
);
|
|
273
|
+
const result = await sendText(
|
|
274
|
+
account.forumUrl,
|
|
275
|
+
channelId,
|
|
276
|
+
deliverPayload.text,
|
|
277
|
+
account.apiKey,
|
|
278
|
+
replyToId,
|
|
279
|
+
sessionId
|
|
280
|
+
);
|
|
281
|
+
if (result.error) {
|
|
282
|
+
log?.error(`[af] 发送失败: ${result.error}`);
|
|
283
|
+
} else {
|
|
284
|
+
log?.info(`[af] 发送成功: messageId=${result.id}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
onError: (err: unknown) => {
|
|
289
|
+
log?.error(`[af] dispatcherOptions.onError: ${String(err)}`);
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
// 禁用流式块合并:收集完所有块后一次性 deliver
|
|
293
|
+
// AgentForum 走 REST API 发消息,没有流式推送能力
|
|
294
|
+
replyOptions: { disableBlockStreaming: true },
|
|
295
|
+
});
|
|
296
|
+
log?.info(`[af] dispatchReply 完成`);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
log?.error(`[af] 处理消息失败: ${String(err)}`);
|
|
299
|
+
// 打印完整堆栈
|
|
300
|
+
if (err instanceof Error && err.stack) {
|
|
301
|
+
log?.error(`[af] 堆栈: ${err.stack}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 建立 WebSocket 连接
|
|
308
|
+
*/
|
|
309
|
+
const connect = async (): Promise<void> => {
|
|
310
|
+
if (isAborted) return;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
cleanup();
|
|
314
|
+
|
|
315
|
+
const wsUrl = account.forumUrl.replace(/^http/, "ws");
|
|
316
|
+
log?.info(`[af] 连接到 ${wsUrl}/ws`);
|
|
317
|
+
|
|
318
|
+
ws = new WebSocket(`${wsUrl}/ws?apiKey=${account.apiKey}`);
|
|
319
|
+
|
|
320
|
+
ws.on("open", () => {
|
|
321
|
+
log?.info("[af] WebSocket 已连接");
|
|
322
|
+
reconnectAttempts = 0;
|
|
323
|
+
onReady?.({});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
ws.on("message", async (raw: WebSocket.RawData) => {
|
|
327
|
+
try {
|
|
328
|
+
const event = JSON.parse(raw.toString()) as AgentForumWSEvent;
|
|
329
|
+
|
|
330
|
+
// 响应服务端心跳
|
|
331
|
+
if (event.type === "ping") {
|
|
332
|
+
ws?.send(
|
|
333
|
+
JSON.stringify({
|
|
334
|
+
type: "pong",
|
|
335
|
+
payload: {},
|
|
336
|
+
timestamp: new Date().toISOString(),
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 处理新消息事件
|
|
343
|
+
if (event.type === "message.new") {
|
|
344
|
+
await handleMessageNew(event.payload as unknown as MessageNewPayload);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 其他事件类型可按需扩展
|
|
349
|
+
log?.debug?.(`[af] 收到事件: ${event.type}`);
|
|
350
|
+
} catch (parseErr) {
|
|
351
|
+
log?.error(`[af] 解析 WS 消息失败: ${String(parseErr)}`);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
ws.on("close", (code: number, reason: Buffer) => {
|
|
356
|
+
log?.info(`[af] 连接关闭: code=${code} reason=${reason.toString()}`);
|
|
357
|
+
cleanup();
|
|
358
|
+
if (!isAborted) scheduleReconnect();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
ws.on("error", (err: Error) => {
|
|
362
|
+
log?.error(`[af] WebSocket 错误: ${err.message}`);
|
|
363
|
+
onError?.(err);
|
|
364
|
+
});
|
|
365
|
+
} catch (err) {
|
|
366
|
+
log?.error(`[af] 连接失败: ${String(err)}`);
|
|
367
|
+
cleanup();
|
|
368
|
+
if (!isAborted) scheduleReconnect();
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// 发起首次连接
|
|
373
|
+
await connect();
|
|
374
|
+
|
|
375
|
+
// 保持 gateway 运行直到 abortSignal 触发
|
|
376
|
+
return new Promise<void>((resolve) => {
|
|
377
|
+
abortSignal.addEventListener("abort", () => resolve());
|
|
378
|
+
});
|
|
379
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentForum CLI Onboarding Adapter
|
|
3
|
+
*
|
|
4
|
+
* 对齐 openclaw-qqbot/src/onboarding.ts,
|
|
5
|
+
* 实现 ChannelOnboardingAdapter 接口,供 `openclaw onboard` 命令使用。
|
|
6
|
+
* 交互式引导用户完成 AgentForum 账户配置。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ChannelOnboardingAdapter,
|
|
11
|
+
OpenClawConfig,
|
|
12
|
+
} from "openclaw/plugin-sdk";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_ACCOUNT_ID,
|
|
15
|
+
listAgentForumAccountIds,
|
|
16
|
+
resolveAgentForumAccount,
|
|
17
|
+
isAgentForumAccountConfigured,
|
|
18
|
+
} from "./config.js";
|
|
19
|
+
|
|
20
|
+
/** 环境变量名,用于非交互式场景下自动读取凭证 */
|
|
21
|
+
const ENV_API_KEY = "AGENTFORUM_API_KEY";
|
|
22
|
+
const ENV_AGENT_ID = "AGENTFORUM_AGENT_ID";
|
|
23
|
+
const ENV_FORUM_URL = "AGENTFORUM_URL";
|
|
24
|
+
|
|
25
|
+
/** Prompter 类型(由 OpenClaw 框架注入的交互原语) */
|
|
26
|
+
interface Prompter {
|
|
27
|
+
note: (message: string, title?: string) => Promise<void>;
|
|
28
|
+
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
|
|
29
|
+
text: (opts: {
|
|
30
|
+
message: string;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
initialValue?: string;
|
|
33
|
+
validate?: (value: string) => string | undefined;
|
|
34
|
+
}) => Promise<string>;
|
|
35
|
+
select: <T>(opts: {
|
|
36
|
+
message: string;
|
|
37
|
+
options: Array<{ value: T; label: string }>;
|
|
38
|
+
initialValue?: T;
|
|
39
|
+
}) => Promise<T>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 解析默认账户 ID
|
|
44
|
+
*/
|
|
45
|
+
function resolveDefaultAccountId(cfg: OpenClawConfig): string {
|
|
46
|
+
const ids = listAgentForumAccountIds(cfg);
|
|
47
|
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* AgentForum Onboarding Adapter
|
|
52
|
+
*/
|
|
53
|
+
export const agentforumOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
54
|
+
channel: "agentforum" as any,
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 获取当前通道的配置状态
|
|
58
|
+
*/
|
|
59
|
+
getStatus: async (ctx) => {
|
|
60
|
+
const cfg = ctx.cfg as OpenClawConfig;
|
|
61
|
+
const configured = listAgentForumAccountIds(cfg).some((accountId) => {
|
|
62
|
+
const account = resolveAgentForumAccount(cfg, accountId);
|
|
63
|
+
return isAgentForumAccountConfigured(account);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
channel: "agentforum" as any,
|
|
68
|
+
configured,
|
|
69
|
+
statusLines: [
|
|
70
|
+
`AgentForum: ${configured ? "已配置" : "需要 API Key 和 Agent ID"}`,
|
|
71
|
+
],
|
|
72
|
+
selectionHint: configured
|
|
73
|
+
? "已配置"
|
|
74
|
+
: "连接 AgentForum 多 Agent 协作平台",
|
|
75
|
+
quickstartScore: configured ? 1 : 30,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 交互式配置向导
|
|
81
|
+
*/
|
|
82
|
+
configure: async (ctx) => {
|
|
83
|
+
const cfg = ctx.cfg as OpenClawConfig;
|
|
84
|
+
const prompter = ctx.prompter as Prompter;
|
|
85
|
+
const accountOverrides = ctx.accountOverrides as Record<string, string> | undefined;
|
|
86
|
+
const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
|
|
87
|
+
|
|
88
|
+
const agentforumOverride = accountOverrides?.agentforum?.trim();
|
|
89
|
+
const defaultAccountId = resolveDefaultAccountId(cfg);
|
|
90
|
+
let accountId = agentforumOverride ?? defaultAccountId;
|
|
91
|
+
|
|
92
|
+
// 多账户选择
|
|
93
|
+
if (shouldPromptAccountIds && !agentforumOverride) {
|
|
94
|
+
const existingIds = listAgentForumAccountIds(cfg);
|
|
95
|
+
if (existingIds.length > 1) {
|
|
96
|
+
accountId = await prompter.select({
|
|
97
|
+
message: "选择 AgentForum 账户",
|
|
98
|
+
options: existingIds.map((id) => ({
|
|
99
|
+
value: id,
|
|
100
|
+
label: id === DEFAULT_ACCOUNT_ID ? "默认账户" : id,
|
|
101
|
+
})),
|
|
102
|
+
initialValue: accountId,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let next: OpenClawConfig = cfg;
|
|
108
|
+
const resolvedAccount = resolveAgentForumAccount(next, accountId);
|
|
109
|
+
const accountConfigured = isAgentForumAccountConfigured(resolvedAccount);
|
|
110
|
+
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
|
111
|
+
const envApiKey = typeof process !== "undefined" ? process.env?.[ENV_API_KEY]?.trim() : undefined;
|
|
112
|
+
const envAgentId = typeof process !== "undefined" ? process.env?.[ENV_AGENT_ID]?.trim() : undefined;
|
|
113
|
+
const envForumUrl = typeof process !== "undefined" ? process.env?.[ENV_FORUM_URL]?.trim() : undefined;
|
|
114
|
+
const canUseEnv = allowEnv && Boolean(envApiKey && envAgentId);
|
|
115
|
+
const hasConfigCredentials = Boolean(resolvedAccount.apiKey && resolvedAccount.agentId);
|
|
116
|
+
|
|
117
|
+
let apiKey: string | null = null;
|
|
118
|
+
let agentId: string | null = null;
|
|
119
|
+
let forumUrl: string | null = null;
|
|
120
|
+
|
|
121
|
+
// 显示帮助
|
|
122
|
+
if (!accountConfigured) {
|
|
123
|
+
await prompter.note(
|
|
124
|
+
[
|
|
125
|
+
"AgentForum 连接配置",
|
|
126
|
+
"",
|
|
127
|
+
"你需要提供:",
|
|
128
|
+
" - API Key (af_xxx 格式,注册 Agent 时返回)",
|
|
129
|
+
" - Agent ID (UUID,注册 Agent 时返回)",
|
|
130
|
+
" - 服务器地址 (默认 http://localhost:3000)",
|
|
131
|
+
"",
|
|
132
|
+
"Channel ID 可选 — 不指定则监听所有已加入频道",
|
|
133
|
+
"Agent 通过 @mention 或 reply 被触发回复",
|
|
134
|
+
"",
|
|
135
|
+
"也可以设置环境变量 AGENTFORUM_API_KEY 和 AGENTFORUM_AGENT_ID",
|
|
136
|
+
].join("\n"),
|
|
137
|
+
"AgentForum 配置",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 检测环境变量
|
|
142
|
+
if (canUseEnv && !hasConfigCredentials) {
|
|
143
|
+
const keepEnv = await prompter.confirm({
|
|
144
|
+
message: `检测到环境变量 ${ENV_API_KEY} 和 ${ENV_AGENT_ID},是否使用?`,
|
|
145
|
+
initialValue: true,
|
|
146
|
+
});
|
|
147
|
+
if (keepEnv) {
|
|
148
|
+
next = {
|
|
149
|
+
...next,
|
|
150
|
+
channels: {
|
|
151
|
+
...next.channels,
|
|
152
|
+
agentforum: {
|
|
153
|
+
...(next.channels?.agentforum as Record<string, unknown> || {}),
|
|
154
|
+
enabled: true,
|
|
155
|
+
apiKey: envApiKey,
|
|
156
|
+
agentId: envAgentId,
|
|
157
|
+
...(envForumUrl ? { forumUrl: envForumUrl } : {}),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return { success: true, cfg: next as any, accountId };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 已有配置
|
|
166
|
+
if (hasConfigCredentials) {
|
|
167
|
+
const keep = await prompter.confirm({
|
|
168
|
+
message: `AgentForum 已配置 (agent: ${resolvedAccount.agentId}),是否保留当前配置?`,
|
|
169
|
+
initialValue: true,
|
|
170
|
+
});
|
|
171
|
+
if (keep) {
|
|
172
|
+
return { success: true, cfg: next as any, accountId };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 手动输入
|
|
177
|
+
forumUrl = String(
|
|
178
|
+
await prompter.text({
|
|
179
|
+
message: "AgentForum 服务器地址",
|
|
180
|
+
placeholder: "http://localhost:3000",
|
|
181
|
+
initialValue: resolvedAccount.forumUrl || "http://localhost:3000",
|
|
182
|
+
}),
|
|
183
|
+
).trim();
|
|
184
|
+
|
|
185
|
+
apiKey = String(
|
|
186
|
+
await prompter.text({
|
|
187
|
+
message: "API Key (af_xxx 格式)",
|
|
188
|
+
placeholder: "af_...",
|
|
189
|
+
initialValue: resolvedAccount.apiKey || undefined,
|
|
190
|
+
validate: (value: string) => {
|
|
191
|
+
if (!value?.trim()) return "API Key 不能为空";
|
|
192
|
+
if (!value.startsWith("af_")) return "API Key 必须以 af_ 开头";
|
|
193
|
+
return undefined;
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
).trim();
|
|
197
|
+
|
|
198
|
+
agentId = String(
|
|
199
|
+
await prompter.text({
|
|
200
|
+
message: "Agent ID (UUID)",
|
|
201
|
+
placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
202
|
+
initialValue: resolvedAccount.agentId || undefined,
|
|
203
|
+
validate: (value: string) => {
|
|
204
|
+
if (!value?.trim()) return "Agent ID 不能为空";
|
|
205
|
+
return undefined;
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
).trim();
|
|
209
|
+
|
|
210
|
+
// 写入配置
|
|
211
|
+
if (apiKey && agentId) {
|
|
212
|
+
const existingSection = (next.channels?.agentforum as Record<string, unknown>) || {};
|
|
213
|
+
|
|
214
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
215
|
+
next = {
|
|
216
|
+
...next,
|
|
217
|
+
channels: {
|
|
218
|
+
...next.channels,
|
|
219
|
+
agentforum: {
|
|
220
|
+
...existingSection,
|
|
221
|
+
enabled: true,
|
|
222
|
+
apiKey,
|
|
223
|
+
agentId,
|
|
224
|
+
forumUrl,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
} else {
|
|
229
|
+
const existingAccounts = (existingSection.accounts as Record<string, unknown>) || {};
|
|
230
|
+
const existingAccount = (existingAccounts[accountId] as Record<string, unknown>) || {};
|
|
231
|
+
|
|
232
|
+
next = {
|
|
233
|
+
...next,
|
|
234
|
+
channels: {
|
|
235
|
+
...next.channels,
|
|
236
|
+
agentforum: {
|
|
237
|
+
...existingSection,
|
|
238
|
+
enabled: true,
|
|
239
|
+
accounts: {
|
|
240
|
+
...existingAccounts,
|
|
241
|
+
[accountId]: {
|
|
242
|
+
...existingAccount,
|
|
243
|
+
enabled: true,
|
|
244
|
+
apiKey,
|
|
245
|
+
agentId,
|
|
246
|
+
forumUrl,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { success: true, cfg: next as any, accountId };
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 禁用 AgentForum 通道
|
|
260
|
+
*/
|
|
261
|
+
disable: (cfg: unknown) => {
|
|
262
|
+
const config = cfg as OpenClawConfig;
|
|
263
|
+
return {
|
|
264
|
+
...config,
|
|
265
|
+
channels: {
|
|
266
|
+
...config.channels,
|
|
267
|
+
agentforum: {
|
|
268
|
+
...(config.channels?.agentforum as Record<string, unknown> || {}),
|
|
269
|
+
enabled: false,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
} as any;
|
|
273
|
+
},
|
|
274
|
+
};
|