@openclaw-channel/socket-chat 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/DEVNOTES.md +219 -0
- package/README.md +215 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +44 -0
- package/src/__sdk-stub__.ts +12 -0
- package/src/api.ts +95 -0
- package/src/channel.ts +395 -0
- package/src/config-schema.test.ts +90 -0
- package/src/config-schema.ts +58 -0
- package/src/config.test.ts +318 -0
- package/src/config.ts +218 -0
- package/src/inbound.test.ts +679 -0
- package/src/inbound.ts +344 -0
- package/src/mqtt-client.ts +274 -0
- package/src/outbound.test.ts +176 -0
- package/src/outbound.ts +175 -0
- package/src/probe.ts +49 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +25 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAccountSnapshot,
|
|
3
|
+
ChannelPlugin,
|
|
4
|
+
ChannelStatusIssue,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import {
|
|
7
|
+
buildBaseChannelStatusSummary,
|
|
8
|
+
buildBaseAccountStatusSnapshot,
|
|
9
|
+
collectStatusIssuesFromLastError,
|
|
10
|
+
} from "openclaw/plugin-sdk";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_ACCOUNT_ID,
|
|
13
|
+
applySocketChatAccountConfig,
|
|
14
|
+
deleteSocketChatAccount,
|
|
15
|
+
isSocketChatAccountConfigured,
|
|
16
|
+
listSocketChatAccountIds,
|
|
17
|
+
normalizeAccountId,
|
|
18
|
+
resolveDefaultSocketChatAccountId,
|
|
19
|
+
resolveSocketChatAccount,
|
|
20
|
+
setSocketChatAccountEnabled,
|
|
21
|
+
type CoreConfig,
|
|
22
|
+
type ResolvedSocketChatAccount,
|
|
23
|
+
} from "./config.js";
|
|
24
|
+
import { probeSocketChatAccount } from "./probe.js";
|
|
25
|
+
import {
|
|
26
|
+
buildTextPayload,
|
|
27
|
+
looksLikeSocketChatTargetId,
|
|
28
|
+
normalizeSocketChatTarget,
|
|
29
|
+
sendSocketChatMessage,
|
|
30
|
+
socketChatOutbound,
|
|
31
|
+
} from "./outbound.js";
|
|
32
|
+
import {
|
|
33
|
+
getActiveMqttClient,
|
|
34
|
+
getActiveMqttConfig,
|
|
35
|
+
monitorSocketChatProviderWithRegistry,
|
|
36
|
+
} from "./mqtt-client.js";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// ChannelPlugin 实现
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export const socketChatPlugin: ChannelPlugin<ResolvedSocketChatAccount> = {
|
|
43
|
+
// -------------------------------------------------------------------------
|
|
44
|
+
// 身份标识
|
|
45
|
+
// -------------------------------------------------------------------------
|
|
46
|
+
id: "socket-chat",
|
|
47
|
+
|
|
48
|
+
meta: {
|
|
49
|
+
id: "socket-chat",
|
|
50
|
+
label: "Socket Chat",
|
|
51
|
+
selectionLabel: "Socket Chat (MQTT plugin)",
|
|
52
|
+
docsPath: "/channels/socket-chat",
|
|
53
|
+
docsLabel: "socket-chat",
|
|
54
|
+
blurb: "MQTT-based IM bridge; configure an API key to connect.",
|
|
55
|
+
order: 90,
|
|
56
|
+
quickstartAllowFrom: true,
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
// 能力声明
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
capabilities: {
|
|
63
|
+
chatTypes: ["direct", "group"],
|
|
64
|
+
media: true, // 支持图片(type: 2)
|
|
65
|
+
reactions: false,
|
|
66
|
+
threads: false,
|
|
67
|
+
polls: false,
|
|
68
|
+
nativeCommands: false,
|
|
69
|
+
blockStreaming: true, // MQTT 不适合流式输出,等 AI 回复完整后再发
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
// 配置 Schema
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
configSchema: {
|
|
76
|
+
schema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
properties: {
|
|
80
|
+
apiKey: { type: "string" },
|
|
81
|
+
apiBaseUrl: { type: "string" },
|
|
82
|
+
name: { type: "string" },
|
|
83
|
+
enabled: { type: "boolean" },
|
|
84
|
+
dmPolicy: { type: "string", enum: ["pairing", "open", "allowlist"] },
|
|
85
|
+
allowFrom: { type: "array", items: { type: "string" } },
|
|
86
|
+
defaultTo: { type: "string" },
|
|
87
|
+
requireMention: { type: "boolean" },
|
|
88
|
+
mqttConfigTtlSec: { type: "number" },
|
|
89
|
+
maxReconnectAttempts: { type: "number" },
|
|
90
|
+
reconnectBaseDelayMs: { type: "number" },
|
|
91
|
+
useTls: { type: "boolean" },
|
|
92
|
+
accounts: {
|
|
93
|
+
type: "object",
|
|
94
|
+
additionalProperties: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
apiKey: { type: "string" },
|
|
98
|
+
apiBaseUrl: { type: "string" },
|
|
99
|
+
name: { type: "string" },
|
|
100
|
+
enabled: { type: "boolean" },
|
|
101
|
+
dmPolicy: { type: "string", enum: ["pairing", "open", "allowlist"] },
|
|
102
|
+
allowFrom: { type: "array", items: { type: "string" } },
|
|
103
|
+
defaultTo: { type: "string" },
|
|
104
|
+
requireMention: { type: "boolean" },
|
|
105
|
+
mqttConfigTtlSec: { type: "number" },
|
|
106
|
+
maxReconnectAttempts: { type: "number" },
|
|
107
|
+
reconnectBaseDelayMs: { type: "number" },
|
|
108
|
+
useTls: { type: "boolean" },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
uiHints: {
|
|
115
|
+
apiKey: { label: "API Key", sensitive: true, help: "用于获取 MQTT 连接配置的 API Key" },
|
|
116
|
+
apiBaseUrl: { label: "API Base URL", help: "后端服务地址,留空使用默认值 https://api-bot.aibotk.com" },
|
|
117
|
+
dmPolicy: { label: "私信策略", help: "pairing=需配对, open=任意人, allowlist=白名单" },
|
|
118
|
+
allowFrom: { label: "允许来源", help: "允许触发 AI 的发送者 ID 列表" },
|
|
119
|
+
requireMention: { label: "群消息需@提及", help: "群组消息是否必须@提及机器人才触发" },
|
|
120
|
+
useTls: { label: "使用 TLS(mqtts://)", advanced: true },
|
|
121
|
+
mqttConfigTtlSec: { label: "MQTT 配置缓存时间(秒)", advanced: true },
|
|
122
|
+
maxReconnectAttempts: { label: "最大重连次数", advanced: true },
|
|
123
|
+
reconnectBaseDelayMs: { label: "重连基础延迟(毫秒)", advanced: true },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
// -------------------------------------------------------------------------
|
|
128
|
+
// 账号配置管理
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
config: {
|
|
131
|
+
listAccountIds: (cfg) => listSocketChatAccountIds(cfg as CoreConfig),
|
|
132
|
+
|
|
133
|
+
resolveAccount: (cfg, accountId) =>
|
|
134
|
+
resolveSocketChatAccount(cfg as CoreConfig, accountId ?? DEFAULT_ACCOUNT_ID),
|
|
135
|
+
|
|
136
|
+
defaultAccountId: (cfg) => resolveDefaultSocketChatAccountId(cfg as CoreConfig),
|
|
137
|
+
|
|
138
|
+
isConfigured: (account) => isSocketChatAccountConfigured(account),
|
|
139
|
+
|
|
140
|
+
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
141
|
+
accountId: account.accountId,
|
|
142
|
+
name: account.name,
|
|
143
|
+
enabled: account.enabled,
|
|
144
|
+
configured: isSocketChatAccountConfigured(account),
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
148
|
+
const account = resolveSocketChatAccount(cfg as CoreConfig, accountId ?? DEFAULT_ACCOUNT_ID);
|
|
149
|
+
return account.config.allowFrom ?? [];
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
153
|
+
allowFrom
|
|
154
|
+
.map((e) => String(e).trim())
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((e) => e.replace(/^socket-chat:/i, "").toLowerCase()),
|
|
157
|
+
|
|
158
|
+
resolveDefaultTo: ({ cfg, accountId }) => {
|
|
159
|
+
const account = resolveSocketChatAccount(cfg as CoreConfig, accountId ?? DEFAULT_ACCOUNT_ID);
|
|
160
|
+
return account.config.defaultTo?.trim() || undefined;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
164
|
+
setSocketChatAccountEnabled({ cfg: cfg as CoreConfig, accountId, enabled }),
|
|
165
|
+
|
|
166
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
167
|
+
deleteSocketChatAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// -------------------------------------------------------------------------
|
|
171
|
+
// 配对(pairing)
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
pairing: {
|
|
174
|
+
idLabel: "socketChatUserId",
|
|
175
|
+
normalizeAllowEntry: (entry) => entry.replace(/^(socket-chat|sc):/i, ""),
|
|
176
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
177
|
+
// 找到有活跃 MQTT 连接的账号发送配对批准通知
|
|
178
|
+
// 优先用 default 账号,fallback 到任意有活跃连接的账号
|
|
179
|
+
const accountIds = listSocketChatAccountIds(cfg as CoreConfig);
|
|
180
|
+
let targetAccountId = DEFAULT_ACCOUNT_ID;
|
|
181
|
+
for (const aid of accountIds) {
|
|
182
|
+
if (getActiveMqttClient(aid)) {
|
|
183
|
+
targetAccountId = aid;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const client = getActiveMqttClient(targetAccountId);
|
|
188
|
+
const mqttConfig = getActiveMqttConfig(targetAccountId);
|
|
189
|
+
if (!client || !mqttConfig) return;
|
|
190
|
+
const payload = buildTextPayload(id, "You have been approved to chat with this assistant.");
|
|
191
|
+
await sendSocketChatMessage({ mqttClient: client, mqttConfig, payload });
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
// 安全策略
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
security: {
|
|
199
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
200
|
+
const resolvedId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
201
|
+
const usesAccountPath = Boolean(
|
|
202
|
+
(cfg as CoreConfig).channels?.["socket-chat"]?.accounts?.[resolvedId],
|
|
203
|
+
);
|
|
204
|
+
const basePath = usesAccountPath
|
|
205
|
+
? `channels.socket-chat.accounts.${resolvedId}.`
|
|
206
|
+
: "channels.socket-chat.";
|
|
207
|
+
return {
|
|
208
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
209
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
210
|
+
policyPath: `${basePath}dmPolicy`,
|
|
211
|
+
allowFromPath: basePath,
|
|
212
|
+
approveHint: `Run: openclaw channels pair socket-chat <userId>`,
|
|
213
|
+
normalizeEntry: (raw) => raw.replace(/^(socket-chat|sc):/i, ""),
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
collectWarnings: ({ account }) => {
|
|
218
|
+
const warnings: string[] = [];
|
|
219
|
+
if (!account.config.allowFrom?.length && account.config.dmPolicy === "open") {
|
|
220
|
+
warnings.push(
|
|
221
|
+
"- socket-chat: dmPolicy=\"open\" allows any sender to trigger AI. " +
|
|
222
|
+
"Consider setting dmPolicy=\"pairing\" or configuring allowFrom.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return warnings;
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
// 群组策略
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
groups: {
|
|
233
|
+
resolveRequireMention: ({ cfg, accountId }) => {
|
|
234
|
+
const account = resolveSocketChatAccount(cfg as CoreConfig, accountId ?? DEFAULT_ACCOUNT_ID);
|
|
235
|
+
// 默认群消息需要 @提及才触发
|
|
236
|
+
return account.config.requireMention !== false;
|
|
237
|
+
},
|
|
238
|
+
resolveToolPolicy: () => undefined,
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
// -------------------------------------------------------------------------
|
|
242
|
+
// 消息目标规范化
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
messaging: {
|
|
245
|
+
normalizeTarget: normalizeSocketChatTarget,
|
|
246
|
+
targetResolver: {
|
|
247
|
+
looksLikeId: looksLikeSocketChatTargetId,
|
|
248
|
+
hint: "<contactId|group:groupId|group:groupId@wxid_mention1,wxid_mention2>",
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
// 出站发送
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
outbound: socketChatOutbound,
|
|
256
|
+
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
// 状态管理
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
status: {
|
|
261
|
+
defaultRuntime: {
|
|
262
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
263
|
+
running: false,
|
|
264
|
+
connected: false,
|
|
265
|
+
reconnectAttempts: 0,
|
|
266
|
+
lastConnectedAt: null,
|
|
267
|
+
lastDisconnect: null,
|
|
268
|
+
lastEventAt: null,
|
|
269
|
+
lastStartAt: null,
|
|
270
|
+
lastStopAt: null,
|
|
271
|
+
lastError: null,
|
|
272
|
+
lastInboundAt: null,
|
|
273
|
+
lastOutboundAt: null,
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
collectStatusIssues: (snapshots): ChannelStatusIssue[] => {
|
|
277
|
+
const issues: ChannelStatusIssue[] = [];
|
|
278
|
+
for (const snap of snapshots) {
|
|
279
|
+
issues.push(...collectStatusIssuesFromLastError("socket-chat", [snap]));
|
|
280
|
+
if (!snap.configured) {
|
|
281
|
+
issues.push({
|
|
282
|
+
channel: "socket-chat",
|
|
283
|
+
accountId: snap.accountId,
|
|
284
|
+
kind: "config",
|
|
285
|
+
message: "socket-chat account is not configured (missing apiKey).",
|
|
286
|
+
fix: "Run: openclaw channels add socket-chat",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return issues;
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
buildChannelSummary: ({ snapshot }) =>
|
|
294
|
+
buildBaseChannelStatusSummary(snapshot),
|
|
295
|
+
|
|
296
|
+
probeAccount: async ({ account, timeoutMs }) =>
|
|
297
|
+
probeSocketChatAccount({ account, timeoutMs }),
|
|
298
|
+
|
|
299
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
|
300
|
+
const configured = isSocketChatAccountConfigured(account);
|
|
301
|
+
const accountWithConfigured = { ...account, configured };
|
|
302
|
+
const base = buildBaseAccountStatusSnapshot({ account: accountWithConfigured, runtime, probe });
|
|
303
|
+
return {
|
|
304
|
+
...base,
|
|
305
|
+
// 补充 probe 信息到 snapshot
|
|
306
|
+
probe,
|
|
307
|
+
...(probe && typeof probe === "object" && "host" in probe
|
|
308
|
+
? { baseUrl: `mqtt://${(probe as { host?: string }).host}:${(probe as { port?: string }).port}` }
|
|
309
|
+
: {}),
|
|
310
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
311
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
// Gateway:启动 MQTT 监听
|
|
318
|
+
// -------------------------------------------------------------------------
|
|
319
|
+
gateway: {
|
|
320
|
+
startAccount: async (ctx) => {
|
|
321
|
+
const account = ctx.account;
|
|
322
|
+
|
|
323
|
+
if (!isSocketChatAccountConfigured(account)) {
|
|
324
|
+
ctx.log?.error?.(
|
|
325
|
+
`[${account.accountId}] socket-chat not configured (missing apiKey)`,
|
|
326
|
+
);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
ctx.log?.info?.(`[${account.accountId}] starting socket-chat MQTT provider`);
|
|
331
|
+
|
|
332
|
+
return monitorSocketChatProviderWithRegistry({
|
|
333
|
+
account,
|
|
334
|
+
accountId: account.accountId,
|
|
335
|
+
ctx,
|
|
336
|
+
log: ctx.log ?? {
|
|
337
|
+
info: () => {},
|
|
338
|
+
warn: () => {},
|
|
339
|
+
error: () => {},
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
logoutAccount: async ({ accountId }) => {
|
|
345
|
+
const client = getActiveMqttClient(accountId);
|
|
346
|
+
client?.end(true);
|
|
347
|
+
return { cleared: true, loggedOut: true };
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// -------------------------------------------------------------------------
|
|
352
|
+
// 配置变更热重载
|
|
353
|
+
// -------------------------------------------------------------------------
|
|
354
|
+
reload: {
|
|
355
|
+
configPrefixes: ["channels.socket-chat"],
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// -------------------------------------------------------------------------
|
|
359
|
+
// CLI setup 向导
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
setup: {
|
|
362
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
363
|
+
|
|
364
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
365
|
+
const apiKey = (input.token ?? "").trim();
|
|
366
|
+
const apiBaseUrl = (input.httpUrl ?? "").trim() || undefined;
|
|
367
|
+
return applySocketChatAccountConfig({
|
|
368
|
+
cfg: cfg as CoreConfig,
|
|
369
|
+
accountId,
|
|
370
|
+
apiKey,
|
|
371
|
+
apiBaseUrl,
|
|
372
|
+
name: input.name,
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
validateInput: ({ input }) => {
|
|
377
|
+
if (!input.token?.trim()) {
|
|
378
|
+
return "socket-chat requires --token <apiKey>";
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
// -------------------------------------------------------------------------
|
|
385
|
+
// Agent prompt 提示词(告诉 AI 如何使用该 channel)
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
agentPrompt: {
|
|
388
|
+
messageToolHints: () => [
|
|
389
|
+
"- socket-chat: to send to a group, use target format `group:<groupId>`. " +
|
|
390
|
+
"To @mention users in a group: `group:<groupId>@<userId1>,<userId2>`.",
|
|
391
|
+
"- socket-chat: to send an image, provide a public HTTP URL as the media parameter.",
|
|
392
|
+
"- socket-chat: direct messages use the sender's contactId (e.g. `wxid_xxx`) as the target.",
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { SocketChatAccountConfigSchema, SocketChatTopLevelConfigSchema } from "./config-schema.js";
|
|
3
|
+
|
|
4
|
+
describe("SocketChatAccountConfigSchema", () => {
|
|
5
|
+
it("accepts a fully specified config", () => {
|
|
6
|
+
const parsed = SocketChatAccountConfigSchema.parse({
|
|
7
|
+
apiKey: "key123",
|
|
8
|
+
apiBaseUrl: "https://example.com",
|
|
9
|
+
name: "My Bot",
|
|
10
|
+
enabled: true,
|
|
11
|
+
dmPolicy: "pairing",
|
|
12
|
+
allowFrom: ["wxid_alice", "wxid_bob"],
|
|
13
|
+
defaultTo: "wxid_alice",
|
|
14
|
+
requireMention: true,
|
|
15
|
+
mqttConfigTtlSec: 600,
|
|
16
|
+
maxReconnectAttempts: 5,
|
|
17
|
+
reconnectBaseDelayMs: 1000,
|
|
18
|
+
useTls: false,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(parsed.apiKey).toBe("key123");
|
|
22
|
+
expect(parsed.dmPolicy).toBe("pairing");
|
|
23
|
+
expect(parsed.allowFrom).toEqual(["wxid_alice", "wxid_bob"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("accepts an empty config (all fields optional)", () => {
|
|
27
|
+
const parsed = SocketChatAccountConfigSchema.parse({});
|
|
28
|
+
expect(parsed).toBeDefined();
|
|
29
|
+
expect(parsed.apiKey).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("validates dmPolicy enum values", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
SocketChatAccountConfigSchema.parse({ dmPolicy: "invalid" }),
|
|
35
|
+
).toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("accepts all valid dmPolicy values", () => {
|
|
39
|
+
for (const policy of ["pairing", "open", "allowlist"] as const) {
|
|
40
|
+
const parsed = SocketChatAccountConfigSchema.parse({ dmPolicy: policy });
|
|
41
|
+
expect(parsed.dmPolicy).toBe(policy);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects non-boolean for enabled field", () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
SocketChatAccountConfigSchema.parse({ enabled: "yes" }),
|
|
48
|
+
).toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("accepts allowFrom as string array", () => {
|
|
52
|
+
const parsed = SocketChatAccountConfigSchema.parse({
|
|
53
|
+
allowFrom: ["wxid_abc", "wxid_def"],
|
|
54
|
+
});
|
|
55
|
+
expect(parsed.allowFrom).toEqual(["wxid_abc", "wxid_def"]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("SocketChatTopLevelConfigSchema", () => {
|
|
60
|
+
it("accepts a multi-account config", () => {
|
|
61
|
+
const parsed = SocketChatTopLevelConfigSchema.parse({
|
|
62
|
+
accounts: {
|
|
63
|
+
default: { apiKey: "key-default", apiBaseUrl: "https://default.com" },
|
|
64
|
+
work: { apiKey: "key-work", apiBaseUrl: "https://work.com" },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
expect(parsed.accounts?.["default"]?.apiKey).toBe("key-default");
|
|
68
|
+
expect(parsed.accounts?.["work"]?.apiKey).toBe("key-work");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("accepts top-level fields alongside accounts map", () => {
|
|
72
|
+
const parsed = SocketChatTopLevelConfigSchema.parse({
|
|
73
|
+
apiKey: "top-key",
|
|
74
|
+
apiBaseUrl: "https://top.com",
|
|
75
|
+
accounts: {
|
|
76
|
+
work: { apiKey: "work-key", apiBaseUrl: "https://work.com" },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(parsed.apiKey).toBe("top-key");
|
|
80
|
+
expect(parsed.accounts?.["work"]?.apiKey).toBe("work-key");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("accepts config without accounts map", () => {
|
|
84
|
+
const parsed = SocketChatTopLevelConfigSchema.parse({
|
|
85
|
+
apiKey: "k",
|
|
86
|
+
apiBaseUrl: "https://x.com",
|
|
87
|
+
});
|
|
88
|
+
expect(parsed.accounts).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* channels.socket-chat 账号配置 Schema
|
|
5
|
+
*
|
|
6
|
+
* 在 openclaw 配置文件(~/.openclaw/config.yaml)中对应:
|
|
7
|
+
*
|
|
8
|
+
* channels:
|
|
9
|
+
* socket-chat:
|
|
10
|
+
* apiKey: "your-api-key"
|
|
11
|
+
* apiBaseUrl: "https://your-server.com"
|
|
12
|
+
* enabled: true
|
|
13
|
+
* dmPolicy: "pairing" # pairing | open | allowlist
|
|
14
|
+
* allowFrom: []
|
|
15
|
+
*
|
|
16
|
+
* 多账号:
|
|
17
|
+
* channels:
|
|
18
|
+
* socket-chat:
|
|
19
|
+
* accounts:
|
|
20
|
+
* work:
|
|
21
|
+
* apiKey: "..."
|
|
22
|
+
* apiBaseUrl: "..."
|
|
23
|
+
*/
|
|
24
|
+
export const SocketChatAccountConfigSchema = z.object({
|
|
25
|
+
/** API Key,用于获取 MQTT 连接信息 */
|
|
26
|
+
apiKey: z.string().optional(),
|
|
27
|
+
/** 后端服务 base URL,例如 https://example.com */
|
|
28
|
+
apiBaseUrl: z.string().optional(),
|
|
29
|
+
/** 账号显示名称 */
|
|
30
|
+
name: z.string().optional(),
|
|
31
|
+
/** 是否启用此账号 */
|
|
32
|
+
enabled: z.boolean().optional(),
|
|
33
|
+
/** DM 安全策略 */
|
|
34
|
+
dmPolicy: z.enum(["pairing", "open", "allowlist"]).optional(),
|
|
35
|
+
/** 允许触发 AI 的发送者 ID 列表 */
|
|
36
|
+
allowFrom: z.array(z.string()).optional(),
|
|
37
|
+
/** 默认发消息目标(contactId 或 group:groupId) */
|
|
38
|
+
defaultTo: z.string().optional(),
|
|
39
|
+
/** 群组消息是否需要 @提及 bot 才触发 */
|
|
40
|
+
requireMention: z.boolean().optional(),
|
|
41
|
+
/** MQTT 连接配置缓存 TTL(秒),默认 300 */
|
|
42
|
+
mqttConfigTtlSec: z.number().optional(),
|
|
43
|
+
/** MQTT 重连最大次数,默认 10 */
|
|
44
|
+
maxReconnectAttempts: z.number().optional(),
|
|
45
|
+
/** MQTT 重连基础延迟(毫秒),默认 2000,指数退避 */
|
|
46
|
+
reconnectBaseDelayMs: z.number().optional(),
|
|
47
|
+
/** MQTT over TLS,默认 false */
|
|
48
|
+
useTls: z.boolean().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type SocketChatAccountConfig = z.infer<typeof SocketChatAccountConfigSchema>;
|
|
52
|
+
|
|
53
|
+
/** 顶层 channels.socket-chat 配置(包含 accounts 多账号支持) */
|
|
54
|
+
export const SocketChatTopLevelConfigSchema = SocketChatAccountConfigSchema.extend({
|
|
55
|
+
accounts: z.record(z.string(), SocketChatAccountConfigSchema).optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export type SocketChatTopLevelConfig = z.infer<typeof SocketChatTopLevelConfigSchema>;
|