@izhimu/qq 0.4.0 → 0.5.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.
- package/README.md +45 -3
- package/dist/src/channel.js +137 -77
- package/dist/src/core/config.d.ts +60 -3
- package/dist/src/core/config.js +37 -5
- package/dist/src/core/dispatch.js +99 -11
- package/dist/src/core/runtime.js +1 -4
- package/dist/src/types/index.d.ts +16 -2
- package/dist/src/utils/log.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
</p>
|
|
28
28
|
|
|
29
29
|
---
|
|
30
|
-

|
|
31
31
|
## 目录
|
|
32
32
|
|
|
33
33
|
- [功能特性](#功能特性)
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
openclaw plugins install @izhimu/qq
|
|
70
70
|
|
|
71
71
|
# 更新插件
|
|
72
|
-
openclaw plugins update
|
|
72
|
+
openclaw plugins update qq
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
### 本地开发安装
|
|
@@ -144,6 +144,10 @@ openclaw gateway restart
|
|
|
144
144
|
| `wsUrl` | `string` | 是 | - | NapCat WebSocket 地址 |
|
|
145
145
|
| `accessToken` | `string` | 否 | `""` | 访问令牌(如配置了认证) |
|
|
146
146
|
| `enabled` | `boolean` | 否 | `true` | 是否启用该账号 |
|
|
147
|
+
| `markdownFormat` | `boolean` | 否 | `true` | 是否启用 Markdown 格式化转换 |
|
|
148
|
+
| `messageDirect` | `object` | 否 | - | 私聊全局配置(策略、黑白名单) |
|
|
149
|
+
| `messageGroup` | `object` | 否 | - | 群组全局配置(@响应、戳一戳、唤醒词等) |
|
|
150
|
+
| `messageGroupsCustom` | `object` | 否 | `{}` | 特定群组的独立配置 |
|
|
147
151
|
|
|
148
152
|
### 配置示例
|
|
149
153
|
|
|
@@ -153,12 +157,26 @@ openclaw gateway restart
|
|
|
153
157
|
"qq": {
|
|
154
158
|
"wsUrl": "ws://127.0.0.1:3001",
|
|
155
159
|
"accessToken": "your-token",
|
|
156
|
-
"enabled": true
|
|
160
|
+
"enabled": true,
|
|
161
|
+
"markdownFormat": true,
|
|
162
|
+
"messageDirect": {
|
|
163
|
+
"policy": "allow",
|
|
164
|
+
"denyFrom": ["12345678"]
|
|
165
|
+
},
|
|
166
|
+
"messageGroup": {
|
|
167
|
+
"requireMention": true,
|
|
168
|
+
"requirePoke": true,
|
|
169
|
+
"policy": "allowlist",
|
|
170
|
+
"allowFrom": ["123456"],
|
|
171
|
+
"wakeWord": "小艺"
|
|
172
|
+
}
|
|
157
173
|
}
|
|
158
174
|
}
|
|
159
175
|
}
|
|
160
176
|
```
|
|
161
177
|
|
|
178
|
+

|
|
179
|
+
|
|
162
180
|
---
|
|
163
181
|
|
|
164
182
|
## 使用方法
|
|
@@ -262,6 +280,7 @@ openclaw-channel-qq/
|
|
|
262
280
|
| `reply` | ✓ | ✓ | 消息回复 |
|
|
263
281
|
| `record` | ✓ | ✓ | 语音消息 |
|
|
264
282
|
| `file` | ✓ | ✓ | 文件 |
|
|
283
|
+
| `video` | ✓ | - | 视频消息 |
|
|
265
284
|
| `json` | ✓ | - | JSON 富文本 |
|
|
266
285
|
|
|
267
286
|
### OneBot 11 接口
|
|
@@ -374,6 +393,29 @@ npm run build
|
|
|
374
393
|
|
|
375
394
|
## 更新日志
|
|
376
395
|
|
|
396
|
+
### [0.5.1] - 2026-03-12
|
|
397
|
+
|
|
398
|
+
#### 修复
|
|
399
|
+
- **连接状态显示**:修复了插件面板中连接状态及错误信息显示不准确的问题。
|
|
400
|
+
|
|
401
|
+
### [0.5.0] - 2026-03-11
|
|
402
|
+
|
|
403
|
+
#### 新增
|
|
404
|
+
- **多媒体支持增强**:新增对视频(`video`)消息类型的入站支持。
|
|
405
|
+
- **细粒度访问控制**:
|
|
406
|
+
- 新增 `messageDirect` 和 `messageGroup` 配置项,支持私聊和群组的独立策略控制(`allow`, `deny`, `allowlist`)。
|
|
407
|
+
- 支持基于用户 ID 的黑白名单过滤。
|
|
408
|
+
- **群组触发增强**:
|
|
409
|
+
- 支持自定义群组唤醒词(`wakeWord`)。
|
|
410
|
+
- 支持开启/关闭戳一戳(`requirePoke`)响应。
|
|
411
|
+
- 支持针对特定群组进行独立配置(`messageGroupsCustom`)。
|
|
412
|
+
- **Markdown 优化**:新增 `markdownFormat` 开关,可选择是否将 Markdown 转换为纯文本。
|
|
413
|
+
|
|
414
|
+
#### 优化
|
|
415
|
+
- **配置结构重构**:重构了配置解析逻辑,提高了配置项的灵活性。
|
|
416
|
+
- **消息文本转换**:优化了多媒体消息(图片、视频、音频、文件等)在日志和历史记录中的纯文本展示效果。
|
|
417
|
+
- **稳定性**:增加了 Gateway Context 的预检,提升了系统的健壮性。
|
|
418
|
+
|
|
377
419
|
### [0.4.0] - 2026-03-07
|
|
378
420
|
|
|
379
421
|
#### 新增
|
package/dist/src/channel.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* QQ NapCat Plugin for OpenClaw
|
|
3
3
|
* Main plugin entry point
|
|
4
4
|
*/
|
|
5
|
-
import { DEFAULT_ACCOUNT_ID, buildChannelConfigSchema, setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from "openclaw/plugin-sdk";
|
|
5
|
+
import { DEFAULT_ACCOUNT_ID, buildChannelConfigSchema, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, normalizeAccountId, waitUntilAbort } from "openclaw/plugin-sdk";
|
|
6
6
|
import { messageIdToString, markdownToText, buildMediaMessage, Logger as log } from "./utils/index.js";
|
|
7
|
-
import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection, setLoginInfo } from "./core/runtime.js";
|
|
7
|
+
import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection, setLoginInfo, getContext } from "./core/runtime.js";
|
|
8
8
|
import { ConnectionManager } from "./core/connection.js";
|
|
9
9
|
import { openClawToNapCatMessage } from "./adapters/message.js";
|
|
10
10
|
import { listQQAccountIds, resolveQQAccount, QQConfigSchema, CHANNEL_ID } from "./core/config.js";
|
|
@@ -32,8 +32,7 @@ export const qqPlugin = {
|
|
|
32
32
|
config: {
|
|
33
33
|
listAccountIds: (cfg) => listQQAccountIds(cfg),
|
|
34
34
|
resolveAccount: (cfg) => resolveQQAccount({ cfg }),
|
|
35
|
-
|
|
36
|
-
isConfigured: (account) => Boolean(account?.wsUrl),
|
|
35
|
+
isConfigured: (account) => !!account.accessToken?.trim(),
|
|
37
36
|
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({
|
|
38
37
|
cfg,
|
|
39
38
|
sectionKey: "qq",
|
|
@@ -46,8 +45,62 @@ export const qqPlugin = {
|
|
|
46
45
|
sectionKey: "qq",
|
|
47
46
|
accountId,
|
|
48
47
|
}),
|
|
48
|
+
describeAccount: (account) => ({
|
|
49
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
50
|
+
tokenSource: account.accessToken ? "config" : "none"
|
|
51
|
+
}),
|
|
49
52
|
},
|
|
50
53
|
configSchema: buildChannelConfigSchema(QQConfigSchema),
|
|
54
|
+
setup: {
|
|
55
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
56
|
+
applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
|
|
57
|
+
cfg: cfg,
|
|
58
|
+
channelKey: "qq",
|
|
59
|
+
accountId,
|
|
60
|
+
name,
|
|
61
|
+
}),
|
|
62
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
63
|
+
const namedConfig = applyAccountNameToChannelSection({
|
|
64
|
+
cfg,
|
|
65
|
+
channelKey: "qq",
|
|
66
|
+
accountId,
|
|
67
|
+
name: input.name,
|
|
68
|
+
});
|
|
69
|
+
const next = accountId !== DEFAULT_ACCOUNT_ID ? migrateBaseNameToDefaultAccount({
|
|
70
|
+
cfg: namedConfig,
|
|
71
|
+
channelKey: "qq",
|
|
72
|
+
}) : namedConfig;
|
|
73
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
74
|
+
return {
|
|
75
|
+
...next,
|
|
76
|
+
channels: {
|
|
77
|
+
...next.channels,
|
|
78
|
+
qq: {
|
|
79
|
+
...next.channels?.["qq"],
|
|
80
|
+
enabled: true,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
...next,
|
|
87
|
+
channels: {
|
|
88
|
+
...next.channels,
|
|
89
|
+
qq: {
|
|
90
|
+
...next.channels?.["qq"],
|
|
91
|
+
enabled: true,
|
|
92
|
+
accounts: {
|
|
93
|
+
...next.channels?.["qq"]?.accounts,
|
|
94
|
+
[accountId]: {
|
|
95
|
+
...next.channels?.["qq"]?.accounts?.[accountId],
|
|
96
|
+
enabled: true,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
},
|
|
51
104
|
messaging: {
|
|
52
105
|
normalizeTarget: (target) => {
|
|
53
106
|
return target.replace(/^qq:/i, "");
|
|
@@ -73,45 +126,32 @@ export const qqPlugin = {
|
|
|
73
126
|
defaultRuntime: {
|
|
74
127
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
75
128
|
name: "QQ",
|
|
76
|
-
enabled: false,
|
|
77
|
-
configured: false,
|
|
78
|
-
linked: false,
|
|
79
129
|
running: false,
|
|
80
130
|
connected: false,
|
|
81
131
|
reconnectAttempts: 0,
|
|
82
132
|
lastConnectedAt: null,
|
|
133
|
+
lastDisconnect: null,
|
|
83
134
|
lastStartAt: null,
|
|
84
135
|
lastStopAt: null,
|
|
85
136
|
lastError: null,
|
|
86
|
-
lastInboundAt: null,
|
|
87
|
-
lastOutboundAt: null,
|
|
88
137
|
},
|
|
89
138
|
buildChannelSummary: ({ snapshot }) => ({
|
|
90
|
-
enabled: snapshot.enabled ?? false,
|
|
91
139
|
configured: snapshot.configured ?? false,
|
|
92
|
-
linked: snapshot.linked ?? false,
|
|
93
140
|
running: snapshot.running ?? false,
|
|
94
|
-
connected: snapshot.connected ?? false,
|
|
95
|
-
reconnectAttempts: snapshot.reconnectAttempts ?? 0,
|
|
96
|
-
lastConnectedAt: snapshot.lastConnectedAt ?? null,
|
|
97
141
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
98
142
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
99
143
|
lastError: snapshot.lastError ?? null,
|
|
100
|
-
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
101
|
-
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
|
102
144
|
probe: snapshot.probe,
|
|
103
145
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
104
146
|
}),
|
|
105
147
|
probeAccount: async () => {
|
|
106
148
|
const status = await getStatus();
|
|
107
|
-
|
|
149
|
+
log.debug('gateway', `Probe status: ${status.status}`);
|
|
108
150
|
setContextStatus({
|
|
109
|
-
linked: ok,
|
|
110
|
-
running: ok,
|
|
111
151
|
lastProbeAt: Date.now(),
|
|
112
152
|
});
|
|
113
153
|
return {
|
|
114
|
-
ok: ok,
|
|
154
|
+
ok: status.status === "ok",
|
|
115
155
|
status: status.retcode,
|
|
116
156
|
error: status.status === "failed" ? status.msg : null,
|
|
117
157
|
};
|
|
@@ -120,12 +160,11 @@ export const qqPlugin = {
|
|
|
120
160
|
return {
|
|
121
161
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
122
162
|
name: "QQ",
|
|
123
|
-
enabled: account.enabled
|
|
163
|
+
enabled: account.enabled,
|
|
124
164
|
configured: Boolean(account.wsUrl?.trim()),
|
|
125
|
-
linked:
|
|
165
|
+
linked: Boolean(account.wsUrl?.trim()),
|
|
126
166
|
running: runtime?.running ?? false,
|
|
127
167
|
connected: runtime?.connected ?? false,
|
|
128
|
-
reconnectAttempts: runtime?.reconnectAttempts ?? 0,
|
|
129
168
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
130
169
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
131
170
|
lastError: runtime?.lastError ?? null,
|
|
@@ -139,69 +178,30 @@ export const qqPlugin = {
|
|
|
139
178
|
gateway: {
|
|
140
179
|
startAccount: async (ctx) => {
|
|
141
180
|
setContext(ctx);
|
|
142
|
-
const { account } = ctx;
|
|
181
|
+
const { account, abortSignal } = ctx;
|
|
143
182
|
log.info('gateway', `Starting gateway`);
|
|
183
|
+
setContextStatus({
|
|
184
|
+
running: true,
|
|
185
|
+
lastStartAt: Date.now(),
|
|
186
|
+
});
|
|
144
187
|
// 检查是否已存在连接
|
|
145
188
|
const existingConnection = getConnection();
|
|
146
189
|
if (existingConnection) {
|
|
147
|
-
log.
|
|
148
|
-
return;
|
|
190
|
+
log.debug('gateway', `A connection is already running`);
|
|
191
|
+
return waitUntilAbort(abortSignal);
|
|
149
192
|
}
|
|
150
|
-
// Create new connection manager
|
|
151
|
-
const connection = new ConnectionManager(account);
|
|
152
|
-
connection.on("event", (event) => eventListener(event));
|
|
153
|
-
connection.on("state-changed", (status) => {
|
|
154
|
-
log.info('gateway', `State: ${status.state}`);
|
|
155
|
-
if (status.state === "connected") {
|
|
156
|
-
setContextStatus({
|
|
157
|
-
linked: true,
|
|
158
|
-
connected: true,
|
|
159
|
-
lastConnectedAt: Date.now(),
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
else if (status.state === "disconnected" || status.state === "failed") {
|
|
163
|
-
setContextStatus({
|
|
164
|
-
linked: false,
|
|
165
|
-
connected: false,
|
|
166
|
-
lastError: status.error,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
connection.on("reconnecting", (info) => {
|
|
171
|
-
log.info('gateway', `Reconnecting: ${info.reason}, attempt ${info.totalAttempts}`);
|
|
172
|
-
setContextStatus({
|
|
173
|
-
linked: false,
|
|
174
|
-
connected: false,
|
|
175
|
-
lastError: `Reconnecting (${info.reason})`,
|
|
176
|
-
reconnectAttempts: info.totalAttempts,
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
193
|
try {
|
|
194
|
+
const connection = new ConnectionManager(account);
|
|
195
|
+
onEvent(connection);
|
|
180
196
|
await connection.start();
|
|
181
197
|
setConnection(connection);
|
|
182
|
-
|
|
183
|
-
const info = await getLoginInfo();
|
|
184
|
-
if (info.data) {
|
|
185
|
-
setLoginInfo({
|
|
186
|
-
userId: info.data.user_id.toString(),
|
|
187
|
-
nickname: info.data.nickname,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
// Update start time
|
|
191
|
-
setContextStatus({
|
|
192
|
-
running: true,
|
|
193
|
-
linked: true,
|
|
194
|
-
connected: true,
|
|
195
|
-
lastStartAt: Date.now(),
|
|
196
|
-
});
|
|
198
|
+
await loadLoginInfo();
|
|
197
199
|
log.info('gateway', `Started gateway`);
|
|
200
|
+
return waitUntilAbort(abortSignal);
|
|
198
201
|
}
|
|
199
202
|
catch (error) {
|
|
200
203
|
log.error('gateway', `Failed to start gateway:`, error);
|
|
201
204
|
setContextStatus({
|
|
202
|
-
running: false,
|
|
203
|
-
linked: false,
|
|
204
|
-
connected: false,
|
|
205
205
|
lastError: error instanceof Error ? error.message : 'Failed to start gateway',
|
|
206
206
|
});
|
|
207
207
|
throw error;
|
|
@@ -215,8 +215,6 @@ export const qqPlugin = {
|
|
|
215
215
|
}
|
|
216
216
|
setContextStatus({
|
|
217
217
|
running: false,
|
|
218
|
-
linked: false,
|
|
219
|
-
connected: false,
|
|
220
218
|
lastStopAt: Date.now(),
|
|
221
219
|
});
|
|
222
220
|
clearContext();
|
|
@@ -239,7 +237,20 @@ export const qqPlugin = {
|
|
|
239
237
|
listPeersLive: getFriends,
|
|
240
238
|
listGroups: getGroups,
|
|
241
239
|
listGroupsLive: getGroups,
|
|
242
|
-
}
|
|
240
|
+
},
|
|
241
|
+
heartbeat: {
|
|
242
|
+
checkReady: async ({ cfg }) => {
|
|
243
|
+
const account = resolveQQAccount({ cfg });
|
|
244
|
+
if (!account.wsUrl) {
|
|
245
|
+
return { ok: false, reason: "not-configured" };
|
|
246
|
+
}
|
|
247
|
+
const connection = getConnection();
|
|
248
|
+
if (!connection?.isConnected) {
|
|
249
|
+
return { ok: false, reason: "not-connected" };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, reason: "ok" };
|
|
252
|
+
},
|
|
253
|
+
},
|
|
243
254
|
};
|
|
244
255
|
async function outboundSend(ctx) {
|
|
245
256
|
const { to, text, mediaUrl, accountId, replyToId } = ctx;
|
|
@@ -250,8 +261,18 @@ async function outboundSend(ctx) {
|
|
|
250
261
|
const chatType = type === "group" ? "group" : "private";
|
|
251
262
|
const chatId = id || to;
|
|
252
263
|
const content = [];
|
|
264
|
+
const context = getContext();
|
|
265
|
+
if (!context) {
|
|
266
|
+
log.warn('dispatch', `No gateway context`);
|
|
267
|
+
return {
|
|
268
|
+
channel: CHANNEL_ID,
|
|
269
|
+
messageId: "",
|
|
270
|
+
error: new Error(`No gateway context`),
|
|
271
|
+
deliveredAt: Date.now(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
253
274
|
if (text) {
|
|
254
|
-
content.push({ type: "text", text: markdownToText(text) });
|
|
275
|
+
content.push({ type: "text", text: context.account.markdownFormat ? markdownToText(text) : text });
|
|
255
276
|
}
|
|
256
277
|
if (mediaUrl) {
|
|
257
278
|
content.push(buildMediaMessage(mediaUrl));
|
|
@@ -312,3 +333,42 @@ async function getGroups() {
|
|
|
312
333
|
name: group.group_name,
|
|
313
334
|
}));
|
|
314
335
|
}
|
|
336
|
+
function onEvent(connection) {
|
|
337
|
+
connection.on("event", (event) => eventListener(event));
|
|
338
|
+
connection.on("state-changed", (status) => {
|
|
339
|
+
log.info('gateway', `Connection state: ${status.state}`);
|
|
340
|
+
if (status.state === "connected") {
|
|
341
|
+
setContextStatus({
|
|
342
|
+
connected: true,
|
|
343
|
+
lastConnectedAt: Date.now(),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
else if (status.state === "disconnected" || status.state === "failed") {
|
|
347
|
+
setContextStatus({
|
|
348
|
+
connected: false,
|
|
349
|
+
lastError: status.error,
|
|
350
|
+
lastDisconnect: {
|
|
351
|
+
at: Date.now(),
|
|
352
|
+
error: status.error,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
connection.on("reconnecting", (info) => {
|
|
358
|
+
log.info('gateway', `Reconnecting: ${info.reason}, attempt ${info.totalAttempts}`);
|
|
359
|
+
setContextStatus({
|
|
360
|
+
lastError: `Reconnecting (${info.reason})`,
|
|
361
|
+
reconnectAttempts: info.totalAttempts,
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
async function loadLoginInfo() {
|
|
366
|
+
// 获取登录信息
|
|
367
|
+
const info = await getLoginInfo();
|
|
368
|
+
if (info.data) {
|
|
369
|
+
setLoginInfo({
|
|
370
|
+
userId: info.data.user_id.toString(),
|
|
371
|
+
nickname: info.data.nickname,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
|
5
5
|
import type { QQConfig } from "../types";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
export declare const CHANNEL_ID = "qq";
|
|
8
|
+
export declare const DEBUG_MODE = false;
|
|
8
9
|
/**
|
|
9
10
|
* 列出所有 QQ 账户ID
|
|
10
11
|
*/
|
|
@@ -15,10 +16,66 @@ export declare function listQQAccountIds(cfg: OpenClawConfig): string[];
|
|
|
15
16
|
export declare function resolveQQAccount(params: {
|
|
16
17
|
cfg: OpenClawConfig;
|
|
17
18
|
}): QQConfig;
|
|
19
|
+
export declare const QQDirectConfigSchema: z.ZodObject<{
|
|
20
|
+
policy: z.ZodDefault<z.ZodEnum<{
|
|
21
|
+
allow: "allow";
|
|
22
|
+
deny: "deny";
|
|
23
|
+
allowlist: "allowlist";
|
|
24
|
+
}>>;
|
|
25
|
+
allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
26
|
+
denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export declare const QQGroupConfigSchema: z.ZodObject<{
|
|
29
|
+
requireMention: z.ZodDefault<z.ZodBoolean>;
|
|
30
|
+
requirePoke: z.ZodDefault<z.ZodBoolean>;
|
|
31
|
+
historyLimit: z.ZodDefault<z.ZodNumber>;
|
|
32
|
+
policy: z.ZodDefault<z.ZodEnum<{
|
|
33
|
+
allow: "allow";
|
|
34
|
+
deny: "deny";
|
|
35
|
+
allowlist: "allowlist";
|
|
36
|
+
}>>;
|
|
37
|
+
allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
38
|
+
denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
39
|
+
wakeWord: z.ZodOptional<z.ZodString>;
|
|
40
|
+
}, z.core.$strip>;
|
|
18
41
|
export declare const QQConfigSchema: z.ZodObject<{
|
|
19
42
|
wsUrl: z.ZodDefault<z.ZodString>;
|
|
20
43
|
accessToken: z.ZodDefault<z.ZodString>;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
44
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
45
|
+
markdownFormat: z.ZodDefault<z.ZodBoolean>;
|
|
46
|
+
messageDirect: z.ZodObject<{
|
|
47
|
+
policy: z.ZodDefault<z.ZodEnum<{
|
|
48
|
+
allow: "allow";
|
|
49
|
+
deny: "deny";
|
|
50
|
+
allowlist: "allowlist";
|
|
51
|
+
}>>;
|
|
52
|
+
allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
53
|
+
denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
54
|
+
}, z.core.$strip>;
|
|
55
|
+
messageGroup: z.ZodObject<{
|
|
56
|
+
requireMention: z.ZodDefault<z.ZodBoolean>;
|
|
57
|
+
requirePoke: z.ZodDefault<z.ZodBoolean>;
|
|
58
|
+
historyLimit: z.ZodDefault<z.ZodNumber>;
|
|
59
|
+
policy: z.ZodDefault<z.ZodEnum<{
|
|
60
|
+
allow: "allow";
|
|
61
|
+
deny: "deny";
|
|
62
|
+
allowlist: "allowlist";
|
|
63
|
+
}>>;
|
|
64
|
+
allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
65
|
+
denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
66
|
+
wakeWord: z.ZodOptional<z.ZodString>;
|
|
67
|
+
}, z.core.$strip>;
|
|
68
|
+
messageGroupsCustom: z.ZodOptional<z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
69
|
+
requireMention: z.ZodDefault<z.ZodBoolean>;
|
|
70
|
+
requirePoke: z.ZodDefault<z.ZodBoolean>;
|
|
71
|
+
historyLimit: z.ZodDefault<z.ZodNumber>;
|
|
72
|
+
policy: z.ZodDefault<z.ZodEnum<{
|
|
73
|
+
allow: "allow";
|
|
74
|
+
deny: "deny";
|
|
75
|
+
allowlist: "allowlist";
|
|
76
|
+
}>>;
|
|
77
|
+
allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
78
|
+
denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
79
|
+
wakeWord: z.ZodOptional<z.ZodString>;
|
|
80
|
+
}, z.core.$strip>>>>;
|
|
24
81
|
}, z.core.$strip>;
|
package/dist/src/core/config.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
export const CHANNEL_ID = "qq";
|
|
7
|
+
export const DEBUG_MODE = false;
|
|
7
8
|
/**
|
|
8
9
|
* 列出所有 QQ 账户ID
|
|
9
10
|
*/
|
|
@@ -22,9 +23,24 @@ export function resolveQQAccount(params) {
|
|
|
22
23
|
return {
|
|
23
24
|
enabled: config?.enabled !== false,
|
|
24
25
|
wsUrl: config?.wsUrl ?? "",
|
|
26
|
+
token: config?.accessToken ?? "",
|
|
25
27
|
accessToken: config?.accessToken,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
markdownFormat: config?.markdownFormat ?? true,
|
|
29
|
+
messageDirect: {
|
|
30
|
+
policy: config?.messageDirect?.policy ?? "allow",
|
|
31
|
+
allowFrom: config?.messageDirect?.allowFrom ?? [],
|
|
32
|
+
denyFrom: config?.messageDirect?.denyFrom ?? [],
|
|
33
|
+
},
|
|
34
|
+
messageGroup: {
|
|
35
|
+
requireMention: config?.messageGroup?.requireMention ?? true,
|
|
36
|
+
requirePoke: config?.messageGroup?.requirePoke ?? true,
|
|
37
|
+
policy: config?.messageGroup?.policy ?? "allow",
|
|
38
|
+
historyLimit: config?.messageGroup?.historyLimit ?? 20,
|
|
39
|
+
allowFrom: config?.messageGroup?.allowFrom ?? [],
|
|
40
|
+
denyFrom: config?.messageGroup?.denyFrom ?? [],
|
|
41
|
+
wakeWord: config?.messageGroup?.wakeWord ?? undefined,
|
|
42
|
+
},
|
|
43
|
+
messageGroupsCustom: config?.messageGroupsCustom ?? {},
|
|
28
44
|
};
|
|
29
45
|
}
|
|
30
46
|
/**
|
|
@@ -35,10 +51,26 @@ const wsUrlSchema = z.string()
|
|
|
35
51
|
.regex(wsUrlRegex, { message: "Invalid WebSocket URL format. Expected: ws://host:port or wss://host:port" })
|
|
36
52
|
.default("ws://127.0.0.1:3001")
|
|
37
53
|
.describe("NapCat Websocket 连接地址");
|
|
54
|
+
export const QQDirectConfigSchema = z.object({
|
|
55
|
+
policy: z.enum(["allow", "deny", "allowlist"]).default("allow").describe("私聊策略"),
|
|
56
|
+
allowFrom: z.array(z.string()).default([]).describe("允许的用户").optional(),
|
|
57
|
+
denyFrom: z.array(z.string()).default([]).describe("拒绝的用户").optional(),
|
|
58
|
+
}).describe("私聊全局配置");
|
|
59
|
+
export const QQGroupConfigSchema = z.object({
|
|
60
|
+
requireMention: z.boolean().default(true).describe("群组是否需要@响应"),
|
|
61
|
+
requirePoke: z.boolean().default(true).describe("群组支持戳一戳响应"),
|
|
62
|
+
historyLimit: z.number().default(20).describe("群组历史记录信息条数"),
|
|
63
|
+
policy: z.enum(["allow", "deny", "allowlist"]).default("allow").describe("群组策略"),
|
|
64
|
+
allowFrom: z.array(z.string()).default([]).describe("群组允许的用户").optional(),
|
|
65
|
+
denyFrom: z.array(z.string()).default([]).describe("群组拒绝的用户").optional(),
|
|
66
|
+
wakeWord: z.string().describe("群组唤醒词").optional(),
|
|
67
|
+
}).describe("群组全局配置");
|
|
38
68
|
export const QQConfigSchema = z.object({
|
|
39
69
|
wsUrl: wsUrlSchema,
|
|
40
70
|
accessToken: z.string().default("access-token").describe("NapCat Websocket Token"),
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
enabled: z.boolean().default(true).describe("是否启用"),
|
|
72
|
+
markdownFormat: z.boolean().default(true).describe("是否启动 Markdown 格式化转换"),
|
|
73
|
+
messageDirect: QQDirectConfigSchema,
|
|
74
|
+
messageGroup: QQGroupConfigSchema,
|
|
75
|
+
messageGroupsCustom: z.record(z.string(), QQGroupConfigSchema).default({}).describe("特定群组配置").optional(),
|
|
44
76
|
});
|
|
@@ -20,7 +20,7 @@ async function contentToPlainText(content) {
|
|
|
20
20
|
return c.text;
|
|
21
21
|
case 'at':
|
|
22
22
|
const target = c.isAll ? '@全体成员' : `@${c.userId}`;
|
|
23
|
-
return `[
|
|
23
|
+
return `[提及]${target}`;
|
|
24
24
|
case 'image':
|
|
25
25
|
return `[图片]${c.url}`;
|
|
26
26
|
case 'audio':
|
|
@@ -83,8 +83,16 @@ async function contextToMedia(content) {
|
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
85
|
async function sendText(isGroup, chatId, text) {
|
|
86
|
-
const
|
|
87
|
-
const
|
|
86
|
+
const contextText = text.replace(/NO_REPLY\s*$/, '');
|
|
87
|
+
const context = getContext();
|
|
88
|
+
if (!context) {
|
|
89
|
+
log.warn('dispatch', `No gateway context`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const messageSegments = [{
|
|
93
|
+
type: 'text',
|
|
94
|
+
data: { text: context.account.markdownFormat ? markdownToText(contextText) : contextText }
|
|
95
|
+
}];
|
|
88
96
|
try {
|
|
89
97
|
await sendMsg({
|
|
90
98
|
message_type: isGroup ? 'group' : 'private',
|
|
@@ -131,17 +139,15 @@ export async function dispatchMessage(params) {
|
|
|
131
139
|
const isGroup = chatType === 'group';
|
|
132
140
|
const config = context.account;
|
|
133
141
|
// At 模式处理
|
|
134
|
-
if (isGroup
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
const hasAtMe = loginInfo.userId && content.includes(`[AT]@${loginInfo.userId}`);
|
|
138
|
-
const hasPoke = content.includes('[动作]') && targetId === loginInfo.userId;
|
|
139
|
-
if (!hasAtAll && !hasAtMe && !hasPoke) {
|
|
142
|
+
if (isGroup) {
|
|
143
|
+
const isMention = mention(content, chatId, targetId);
|
|
144
|
+
if (!isMention) {
|
|
140
145
|
log.debug('dispatch', `Skipping group message (not mentioned)`);
|
|
146
|
+
const groupConfig = getGroupConfig(chatId, config);
|
|
141
147
|
recordPendingHistoryEntry({
|
|
142
148
|
historyMap: historyCache,
|
|
143
149
|
historyKey: chatId,
|
|
144
|
-
limit:
|
|
150
|
+
limit: groupConfig.historyLimit ?? 20,
|
|
145
151
|
entry: {
|
|
146
152
|
sender: `${senderName}(${senderId})`,
|
|
147
153
|
body: content,
|
|
@@ -171,10 +177,11 @@ export async function dispatchMessage(params) {
|
|
|
171
177
|
log.info('dispatch', `Aborted previous session`);
|
|
172
178
|
}
|
|
173
179
|
if (isGroup) {
|
|
180
|
+
const groupConfig = getGroupConfig(chatId, config);
|
|
174
181
|
content = buildPendingHistoryContextFromMap({
|
|
175
182
|
historyMap: historyCache,
|
|
176
183
|
historyKey: chatId,
|
|
177
|
-
limit:
|
|
184
|
+
limit: groupConfig.historyLimit ?? 20,
|
|
178
185
|
currentMessage: content,
|
|
179
186
|
formatEntry: (e) => `${e.sender}: ${e.body}`,
|
|
180
187
|
});
|
|
@@ -295,6 +302,10 @@ export async function dispatchMessage(params) {
|
|
|
295
302
|
* Handle group message event
|
|
296
303
|
*/
|
|
297
304
|
export async function handleGroupMessage(event) {
|
|
305
|
+
if (!allow(event.user_id.toString(), event.group_id.toString())) {
|
|
306
|
+
log.debug('dispatch', `Ignoring group message from ${event.user_id}`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
298
309
|
const content = await napCatToOpenClawMessage(event.message);
|
|
299
310
|
const plainText = await contentToPlainText(content);
|
|
300
311
|
const media = await contextToMedia(content);
|
|
@@ -314,6 +325,10 @@ export async function handleGroupMessage(event) {
|
|
|
314
325
|
* Handle private message event
|
|
315
326
|
*/
|
|
316
327
|
export async function handlePrivateMessage(event) {
|
|
328
|
+
if (!allow(event.user_id.toString())) {
|
|
329
|
+
log.debug('dispatch', `Ignoring message from ${event.user_id}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
317
332
|
const content = await napCatToOpenClawMessage(event.message);
|
|
318
333
|
const plainText = await contentToPlainText(content);
|
|
319
334
|
const media = await contextToMedia(content);
|
|
@@ -339,6 +354,10 @@ function extractPokeActionText(rawInfo) {
|
|
|
339
354
|
return actionItem?.txt || '戳了戳';
|
|
340
355
|
}
|
|
341
356
|
export async function handlePokeEvent(event) {
|
|
357
|
+
if (!allow(event.user_id.toString(), event.group_id?.toString())) {
|
|
358
|
+
log.debug('dispatch', `Poke from ${event.user_id} is not allowed`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
342
361
|
const actionText = extractPokeActionText(event.raw_info);
|
|
343
362
|
log.info('dispatch', `Poke from ${event.user_id}: ${actionText}`);
|
|
344
363
|
const pokeMessage = actionText || '戳了戳';
|
|
@@ -355,3 +374,72 @@ export async function handlePokeEvent(event) {
|
|
|
355
374
|
targetId: String(event.target_id),
|
|
356
375
|
});
|
|
357
376
|
}
|
|
377
|
+
function getGroupConfig(groupId, config) {
|
|
378
|
+
log.debug('dispatch', `All Custom config: ${JSON.stringify(config.messageGroupsCustom)}`);
|
|
379
|
+
let groupConfig = config.messageGroupsCustom[groupId];
|
|
380
|
+
if (!groupConfig) {
|
|
381
|
+
groupConfig = config.messageGroup;
|
|
382
|
+
log.debug('dispatch', `Use global config: ${JSON.stringify(groupConfig)}`);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
groupConfig = {
|
|
386
|
+
...config.messageGroup,
|
|
387
|
+
...groupConfig,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
log.debug('dispatch', `Final config: ${JSON.stringify(groupConfig)}`);
|
|
391
|
+
return groupConfig;
|
|
392
|
+
}
|
|
393
|
+
function allow(userId, groupId) {
|
|
394
|
+
const context = getContext();
|
|
395
|
+
if (!context) {
|
|
396
|
+
log.warn('dispatch', `No gateway context`);
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
let config = groupId ? getGroupConfig(groupId, context.account) : context.account.messageDirect;
|
|
400
|
+
return allowJudgment(config, userId);
|
|
401
|
+
}
|
|
402
|
+
function allowJudgment(config, userId) {
|
|
403
|
+
if (config.denyFrom?.includes(userId)) {
|
|
404
|
+
log.debug('dispatch', `User ${userId} is denied`);
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
if (config.policy === 'allow') {
|
|
408
|
+
log.debug('dispatch', `User ${userId} is allowed`);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
if (config.policy === 'deny') {
|
|
412
|
+
log.debug('dispatch', `User ${userId} is denied`);
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
if (config.allowFrom?.includes(userId)) {
|
|
416
|
+
log.debug('dispatch', `User ${userId} is allowed`);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
function mention(content, groupId, targetId) {
|
|
422
|
+
const context = getContext();
|
|
423
|
+
if (!context) {
|
|
424
|
+
log.warn('dispatch', `No gateway context`);
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
let config = getGroupConfig(groupId, context.account);
|
|
428
|
+
const loginInfo = getLoginInfo();
|
|
429
|
+
const isMentionEnabled = !!config?.requireMention;
|
|
430
|
+
const isPokeEnabled = !!config?.requirePoke;
|
|
431
|
+
const isWakeEnabled = !!config?.wakeWord?.trim();
|
|
432
|
+
if (!isMentionEnabled && !isPokeEnabled && !isWakeEnabled) {
|
|
433
|
+
log.debug('dispatch', 'All requires are disabled, returning true by default.');
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
const requireMention = isMentionEnabled &&
|
|
437
|
+
(content.includes('[提及]@全体成员') ||
|
|
438
|
+
(!!loginInfo?.userId && content.includes(`[提及]@${loginInfo.userId}`)));
|
|
439
|
+
const requirePoke = isPokeEnabled &&
|
|
440
|
+
(content.includes('[动作]') && targetId === loginInfo?.userId);
|
|
441
|
+
const requireWake = isWakeEnabled &&
|
|
442
|
+
content.includes(config.wakeWord ?? "");
|
|
443
|
+
log.debug('dispatch', `Require mention: ${requireMention}, require poke: ${requirePoke}, require wake: ${requireWake}`);
|
|
444
|
+
return requireMention || requirePoke || requireWake;
|
|
445
|
+
}
|
package/dist/src/core/runtime.js
CHANGED
|
@@ -27,10 +27,7 @@ export function clearContext() {
|
|
|
27
27
|
}
|
|
28
28
|
export function setContextStatus(next) {
|
|
29
29
|
if (context) {
|
|
30
|
-
context.setStatus(
|
|
31
|
-
...context.getStatus(),
|
|
32
|
-
...next,
|
|
33
|
-
});
|
|
30
|
+
context.setStatus(next);
|
|
34
31
|
}
|
|
35
32
|
}
|
|
36
33
|
// =============================================================================
|
|
@@ -278,10 +278,24 @@ export interface DispatchMessageParams {
|
|
|
278
278
|
}
|
|
279
279
|
export interface QQConfig {
|
|
280
280
|
wsUrl: string;
|
|
281
|
+
token?: string;
|
|
281
282
|
accessToken?: string;
|
|
282
283
|
enabled: boolean;
|
|
283
|
-
|
|
284
|
-
|
|
284
|
+
markdownFormat: boolean;
|
|
285
|
+
messageDirect: QQAllowConfig;
|
|
286
|
+
messageGroup: QQGroupConfig;
|
|
287
|
+
messageGroupsCustom: Record<string, QQGroupConfig>;
|
|
288
|
+
}
|
|
289
|
+
export interface QQGroupConfig extends QQAllowConfig {
|
|
290
|
+
requireMention: boolean;
|
|
291
|
+
requirePoke: boolean;
|
|
292
|
+
historyLimit: number;
|
|
293
|
+
wakeWord?: string;
|
|
294
|
+
}
|
|
295
|
+
export interface QQAllowConfig {
|
|
296
|
+
policy: "allow" | "deny" | "allowlist";
|
|
297
|
+
allowFrom: string[];
|
|
298
|
+
denyFrom: string[];
|
|
285
299
|
}
|
|
286
300
|
export type QQProbe = {
|
|
287
301
|
ok: boolean;
|
package/dist/src/utils/log.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEBUG_MODE } from "../core/config";
|
|
1
2
|
import { getRuntime } from "../core/runtime.js";
|
|
2
3
|
function log() {
|
|
3
4
|
return getRuntime()?.logging.getChildLogger({ module: 'channel/qq' }) ?? console;
|
|
@@ -9,7 +10,9 @@ function param(args) {
|
|
|
9
10
|
}
|
|
10
11
|
export class Logger {
|
|
11
12
|
static debug(category, message, ...args) {
|
|
12
|
-
|
|
13
|
+
if (DEBUG_MODE) {
|
|
14
|
+
log().debug?.(`[${category}] ${message}${param(args)}`);
|
|
15
|
+
}
|
|
13
16
|
}
|
|
14
17
|
static info(category, message, ...args) {
|
|
15
18
|
log().info?.(`[${category}] ${message}${param(args)}`);
|