@izhimu/qq 0.2.3 → 0.3.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 +62 -3
- package/dist/src/channel.d.ts +2 -2
- package/dist/src/channel.js +96 -21
- package/dist/src/core/config.d.ts +1 -1
- package/dist/src/core/config.js +8 -1
- package/dist/src/core/connection.d.ts +15 -3
- package/dist/src/core/connection.js +94 -16
- package/dist/src/core/dispatch.d.ts +1 -1
- package/dist/src/core/dispatch.js +32 -4
- package/dist/src/core/request.d.ts +6 -2
- package/dist/src/core/request.js +25 -2
- package/dist/src/types/index.d.ts +10 -0
- package/dist/src/utils/index.d.ts +8 -0
- package/dist/src/utils/index.js +32 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# @izhimu/qq
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
+
<a href="https://github.com/izhimu/openclaw-channel-qq/releases">
|
|
5
|
+
<img src="https://img.shields.io/github/v/release/izhimu/openclaw-channel-qq?display_name=tag" alt="Release">
|
|
6
|
+
</a>
|
|
4
7
|
<a href="https://opensource.org/licenses/MIT">
|
|
5
8
|
<img src="https://img.shields.io/badge/License-MIT-green.svg" alt="License: MIT">
|
|
6
9
|
</a>
|
|
@@ -36,6 +39,7 @@
|
|
|
36
39
|
- [API 文档](#api-文档)
|
|
37
40
|
- [开发指南](#开发指南)
|
|
38
41
|
- [故障排查](#故障排查)
|
|
42
|
+
- [更新日志](#更新日志)
|
|
39
43
|
|
|
40
44
|
---
|
|
41
45
|
|
|
@@ -44,7 +48,8 @@
|
|
|
44
48
|
- **多渠道支持** - 同时支持 QQ 私聊和群聊
|
|
45
49
|
- **消息类型** - 文本、@提及、图片、表情、语音、文件、回复
|
|
46
50
|
- **实时通信** - WebSocket 全双工连接,支持自动重连
|
|
47
|
-
- **心跳监测** -
|
|
51
|
+
- **心跳监测** - 内置健康检查、心跳超时检测和连接状态监控
|
|
52
|
+
- **媒体支持** - 支持图片、语音、文件等媒体消息的收发
|
|
48
53
|
- **交互式配置** - 提供向导式配置界面
|
|
49
54
|
- **TypeScript** - 完整的类型定义和类型安全
|
|
50
55
|
|
|
@@ -252,8 +257,8 @@ openclaw-channel-qq/
|
|
|
252
257
|
| `image` | ✓ | ✓ | 图片 |
|
|
253
258
|
| `face` | ✓ | - | QQ 表情 |
|
|
254
259
|
| `reply` | ✓ | ✓ | 消息回复 |
|
|
255
|
-
| `record` | ✓ |
|
|
256
|
-
| `file` | ✓ |
|
|
260
|
+
| `record` | ✓ | ✓ | 语音消息 |
|
|
261
|
+
| `file` | ✓ | ✓ | 文件 |
|
|
257
262
|
| `json` | ✓ | - | JSON 富文本 |
|
|
258
263
|
|
|
259
264
|
### OneBot 11 接口
|
|
@@ -364,6 +369,60 @@ npm run build
|
|
|
364
369
|
|
|
365
370
|
---
|
|
366
371
|
|
|
372
|
+
## 更新日志
|
|
373
|
+
|
|
374
|
+
### [0.3.0] - 2026-02-12
|
|
375
|
+
|
|
376
|
+
#### 新增
|
|
377
|
+
- 心跳超时检测和自动重连机制
|
|
378
|
+
- 媒体消息处理功能(图片、语音、文件等)
|
|
379
|
+
- 媒体文件发送功能
|
|
380
|
+
- 运行状态标记和探针功能
|
|
381
|
+
- 通道状态管理功能
|
|
382
|
+
|
|
383
|
+
#### 修复
|
|
384
|
+
- 修复连接状态管理逻辑
|
|
385
|
+
- 修复空消息过滤逻辑
|
|
386
|
+
|
|
387
|
+
#### 重构
|
|
388
|
+
- 重构通道状态管理逻辑
|
|
389
|
+
|
|
390
|
+
### [0.2.3] - 2026-02-09
|
|
391
|
+
|
|
392
|
+
#### 新增
|
|
393
|
+
- Markdown 文本转换功能
|
|
394
|
+
|
|
395
|
+
### [0.2.2] - 2026-02-08
|
|
396
|
+
|
|
397
|
+
#### 新增
|
|
398
|
+
- 账号启用和删除功能
|
|
399
|
+
- 消息回复功能
|
|
400
|
+
- Markdown 解析优化
|
|
401
|
+
|
|
402
|
+
#### 修复
|
|
403
|
+
- 修复多 Agent 通信消息回调问题
|
|
404
|
+
- 修复回复消息格式问题
|
|
405
|
+
- 修复字符串连接时换行符丢失问题
|
|
406
|
+
|
|
407
|
+
### [0.2.1] - 2026-02-08
|
|
408
|
+
|
|
409
|
+
#### 优化
|
|
410
|
+
- 优化 Markdown 转文本功能(移动端适配)
|
|
411
|
+
|
|
412
|
+
### [0.2.0] - 2026-02-07
|
|
413
|
+
|
|
414
|
+
#### 重构
|
|
415
|
+
- 重构 QQ 频道消息发送功能
|
|
416
|
+
|
|
417
|
+
### [0.1.1] - 2026-02-07
|
|
418
|
+
|
|
419
|
+
#### 优化
|
|
420
|
+
- 重构项目配置并更新文档
|
|
421
|
+
- 重构 QQ 频道插件的类型定义和配置管理
|
|
422
|
+
- 优化日志系统
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
367
426
|
## 相关链接
|
|
368
427
|
|
|
369
428
|
- [OpenClaw 官方文档](https://docs.openclaw.ai/)
|
package/dist/src/channel.d.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
* QQ NapCat Plugin for OpenClaw
|
|
3
3
|
* Main plugin entry point
|
|
4
4
|
*/
|
|
5
|
-
import { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
6
|
-
import type { QQConfig } from "./types
|
|
5
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
6
|
+
import type { QQConfig } from "./types";
|
|
7
7
|
export declare const qqPlugin: ChannelPlugin<QQConfig>;
|
package/dist/src/channel.js
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* QQ NapCat Plugin for OpenClaw
|
|
3
3
|
* Main plugin entry point
|
|
4
4
|
*/
|
|
5
|
-
import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from "openclaw/plugin-sdk";
|
|
6
|
-
import {
|
|
7
|
-
import { messageIdToString, markdownToText, getFileType, getFileName, Logger as log } from "./utils/index.js";
|
|
5
|
+
import { buildChannelConfigSchema, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
6
|
+
import { messageIdToString, markdownToText, buildMediaMessage, Logger as log } from "./utils/index.js";
|
|
8
7
|
import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection } from "./core/runtime.js";
|
|
9
8
|
import { ConnectionManager } from "./core/connection.js";
|
|
10
9
|
import { openClawToNapCatMessage } from "./adapters/message.js";
|
|
11
10
|
import { listQQAccountIds, resolveQQAccount, QQConfigSchema, CHANNEL_ID } from "./core/config.js";
|
|
12
|
-
import { eventListener, sendMsg } from "./core/request.js";
|
|
11
|
+
import { eventListener, sendMsg, getStatus } from "./core/request.js";
|
|
13
12
|
import { qqOnboardingAdapter } from "./onboarding.js";
|
|
14
13
|
export const qqPlugin = {
|
|
15
14
|
id: CHANNEL_ID,
|
|
@@ -71,13 +70,67 @@ export const qqPlugin = {
|
|
|
71
70
|
sendMedia: outboundSend,
|
|
72
71
|
},
|
|
73
72
|
status: {
|
|
74
|
-
|
|
73
|
+
defaultRuntime: {
|
|
74
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
75
|
+
name: "QQ",
|
|
76
|
+
enabled: false,
|
|
77
|
+
configured: false,
|
|
78
|
+
linked: false,
|
|
79
|
+
running: false,
|
|
80
|
+
connected: false,
|
|
81
|
+
reconnectAttempts: 0,
|
|
82
|
+
lastConnectedAt: null,
|
|
83
|
+
lastStartAt: null,
|
|
84
|
+
lastStopAt: null,
|
|
85
|
+
lastError: null,
|
|
86
|
+
lastInboundAt: null,
|
|
87
|
+
lastOutboundAt: null,
|
|
88
|
+
},
|
|
89
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
90
|
+
enabled: snapshot.enabled ?? false,
|
|
91
|
+
configured: snapshot.configured ?? false,
|
|
92
|
+
linked: snapshot.linked ?? false,
|
|
93
|
+
running: snapshot.running ?? false,
|
|
94
|
+
connected: snapshot.connected ?? false,
|
|
95
|
+
reconnectAttempts: snapshot.reconnectAttempts ?? 0,
|
|
96
|
+
lastConnectedAt: snapshot.lastConnectedAt ?? null,
|
|
97
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
98
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
99
|
+
lastError: snapshot.lastError ?? null,
|
|
100
|
+
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
101
|
+
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
|
102
|
+
probe: snapshot.probe,
|
|
103
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
104
|
+
}),
|
|
105
|
+
probeAccount: async () => {
|
|
106
|
+
const status = await getStatus();
|
|
107
|
+
setContextStatus({
|
|
108
|
+
running: true,
|
|
109
|
+
lastProbeAt: Date.now(),
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
ok: status.status === "ok",
|
|
113
|
+
status: status.retcode,
|
|
114
|
+
error: status.status === "failed" ? status.msg : null,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
|
75
118
|
return {
|
|
76
119
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
77
|
-
name:
|
|
78
|
-
enabled: account.enabled,
|
|
79
|
-
configured: Boolean(account.wsUrl),
|
|
80
|
-
|
|
120
|
+
name: "QQ",
|
|
121
|
+
enabled: account.enabled ?? false,
|
|
122
|
+
configured: Boolean(account.wsUrl?.trim()),
|
|
123
|
+
linked: runtime?.linked ?? false,
|
|
124
|
+
running: runtime?.running ?? false,
|
|
125
|
+
connected: runtime?.connected ?? false,
|
|
126
|
+
reconnectAttempts: runtime?.reconnectAttempts ?? 0,
|
|
127
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
128
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
129
|
+
lastError: runtime?.lastError ?? null,
|
|
130
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
131
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
132
|
+
probe,
|
|
133
|
+
lastProbeAt: runtime?.lastProbeAt ?? null,
|
|
81
134
|
};
|
|
82
135
|
},
|
|
83
136
|
},
|
|
@@ -87,8 +140,7 @@ export const qqPlugin = {
|
|
|
87
140
|
const { account } = ctx;
|
|
88
141
|
log.info('gateway', `Starting gateway`);
|
|
89
142
|
// Update start time
|
|
90
|
-
|
|
91
|
-
...ctx.getStatus(),
|
|
143
|
+
setContextStatus({
|
|
92
144
|
running: true,
|
|
93
145
|
lastStartAt: Date.now(),
|
|
94
146
|
});
|
|
@@ -110,6 +162,14 @@ export const qqPlugin = {
|
|
|
110
162
|
});
|
|
111
163
|
}
|
|
112
164
|
});
|
|
165
|
+
connection.on("reconnecting", (info) => {
|
|
166
|
+
log.info('gateway', `Reconnecting: ${info.reason}, attempt ${info.totalAttempts}`);
|
|
167
|
+
setContextStatus({
|
|
168
|
+
connected: false,
|
|
169
|
+
lastError: `Reconnecting (${info.reason})`,
|
|
170
|
+
reconnectAttempts: info.totalAttempts,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
113
173
|
await connection.start();
|
|
114
174
|
setConnection(connection);
|
|
115
175
|
log.info('gateway', `Started gateway`);
|
|
@@ -128,6 +188,30 @@ export const qqPlugin = {
|
|
|
128
188
|
clearContext();
|
|
129
189
|
},
|
|
130
190
|
},
|
|
191
|
+
heartbeat: {
|
|
192
|
+
checkReady: async () => {
|
|
193
|
+
const status = await getStatus();
|
|
194
|
+
if (status.status === "ok" && status.data?.online && status.data?.good) {
|
|
195
|
+
setContextStatus({
|
|
196
|
+
linked: true,
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
reason: 'ok'
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
log.warn('heartbeat', `Heartbeat failed, status: ${status.status}, data: ${status.data}`);
|
|
205
|
+
setContextStatus({
|
|
206
|
+
linked: false,
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
reason: status.msg
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
131
215
|
};
|
|
132
216
|
async function outboundSend(ctx) {
|
|
133
217
|
const { to, text, mediaUrl, accountId, replyToId } = ctx;
|
|
@@ -142,16 +226,7 @@ async function outboundSend(ctx) {
|
|
|
142
226
|
content.push({ type: "text", text: markdownToText(text) });
|
|
143
227
|
}
|
|
144
228
|
if (mediaUrl) {
|
|
145
|
-
|
|
146
|
-
case "image":
|
|
147
|
-
content.push({ type: "image", url: mediaUrl.trim() });
|
|
148
|
-
break;
|
|
149
|
-
case "audio":
|
|
150
|
-
content.push({ type: "audio", path: mediaUrl.trim(), url: mediaUrl.trim(), file: getFileName(mediaUrl.trim()) });
|
|
151
|
-
break;
|
|
152
|
-
default:
|
|
153
|
-
content.push({ type: "file", url: mediaUrl.trim(), file: getFileName(mediaUrl.trim()) });
|
|
154
|
-
}
|
|
229
|
+
content.push(buildMediaMessage(mediaUrl));
|
|
155
230
|
}
|
|
156
231
|
if (replyToId) {
|
|
157
232
|
content.push({ type: "reply", messageId: replyToId });
|
package/dist/src/core/config.js
CHANGED
|
@@ -25,8 +25,15 @@ export function resolveQQAccount(params) {
|
|
|
25
25
|
accessToken: config?.accessToken,
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Custom Zod refinement to validate WebSocket URL format
|
|
30
|
+
*/
|
|
31
|
+
const wsUrlRegex = /^wss?:\/\/[\w.-]+(:\d+)?(\/[\w./-]*)?$/;
|
|
32
|
+
const wsUrlSchema = z.string()
|
|
33
|
+
.regex(wsUrlRegex, { message: "Invalid WebSocket URL format. Expected: ws://host:port or wss://host:port" })
|
|
34
|
+
.default("ws://127.0.0.1:3001");
|
|
28
35
|
export const QQConfigSchema = z.object({
|
|
29
|
-
wsUrl:
|
|
36
|
+
wsUrl: wsUrlSchema,
|
|
30
37
|
accessToken: z.string().default("access-token"),
|
|
31
38
|
enable: z.boolean().default(true)
|
|
32
39
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Handles per-account WebSocket connections with auto-reconnect and heartbeat
|
|
4
4
|
*/
|
|
5
5
|
import EventEmitter from 'events';
|
|
6
|
-
import type { NapCatResp, QQConfig, ConnectionStatus, NapCatAction } from '../types
|
|
6
|
+
import type { NapCatResp, QQConfig, ConnectionStatus, NapCatAction } from '../types';
|
|
7
7
|
/**
|
|
8
8
|
* Connection Manager for a single NapCat account
|
|
9
9
|
*/
|
|
@@ -12,9 +12,9 @@ export declare class ConnectionManager extends EventEmitter {
|
|
|
12
12
|
private ws;
|
|
13
13
|
private state;
|
|
14
14
|
private lastHeartbeatTime;
|
|
15
|
-
private
|
|
15
|
+
private heartbeatCheckTimer?;
|
|
16
16
|
private reconnectTimer?;
|
|
17
|
-
private
|
|
17
|
+
private totalReconnectAttempts;
|
|
18
18
|
private shouldReconnect;
|
|
19
19
|
private pendingRequests;
|
|
20
20
|
private healthStatus;
|
|
@@ -31,6 +31,18 @@ export declare class ConnectionManager extends EventEmitter {
|
|
|
31
31
|
* Establish WebSocket connection
|
|
32
32
|
*/
|
|
33
33
|
private connect;
|
|
34
|
+
/**
|
|
35
|
+
* Clear all pending requests and reject them with an error
|
|
36
|
+
*/
|
|
37
|
+
private clearPendingRequests;
|
|
38
|
+
/**
|
|
39
|
+
* Start heartbeat timeout detection
|
|
40
|
+
*/
|
|
41
|
+
private startHeartbeatCheck;
|
|
42
|
+
/**
|
|
43
|
+
* Stop heartbeat timeout detection
|
|
44
|
+
*/
|
|
45
|
+
private stopHeartbeatCheck;
|
|
34
46
|
/**
|
|
35
47
|
* Close WebSocket connection
|
|
36
48
|
*/
|
|
@@ -7,6 +7,8 @@ import EventEmitter from 'events';
|
|
|
7
7
|
import { Logger as log, generateEchoId, calculateBackoff, getCloseCodeMessage, } from '../utils/index.js';
|
|
8
8
|
const MAX_RECONNECT_ATTEMPTS = -1;
|
|
9
9
|
const REQUEST_TIMEOUT = 30000; // 30 seconds
|
|
10
|
+
const HEARTBEAT_TIMEOUT = 60000; // 60 seconds - time without heartbeat before reconnecting
|
|
11
|
+
const HEARTBEAT_CHECK_INTERVAL = 30000; // 30 seconds - how often to check for heartbeat timeout
|
|
10
12
|
/**
|
|
11
13
|
* Connection Manager for a single NapCat account
|
|
12
14
|
*/
|
|
@@ -16,11 +18,10 @@ export class ConnectionManager extends EventEmitter {
|
|
|
16
18
|
state = 'disconnected';
|
|
17
19
|
// Heartbeat - active ping + OneBot 11 meta_event based
|
|
18
20
|
lastHeartbeatTime = 0;
|
|
19
|
-
|
|
20
|
-
totalReconnectAttempts = 0;
|
|
21
|
+
heartbeatCheckTimer;
|
|
21
22
|
// Reconnection
|
|
22
23
|
reconnectTimer;
|
|
23
|
-
|
|
24
|
+
totalReconnectAttempts = 0;
|
|
24
25
|
shouldReconnect = true;
|
|
25
26
|
// Pending requests
|
|
26
27
|
pendingRequests = new Map();
|
|
@@ -46,7 +47,6 @@ export class ConnectionManager extends EventEmitter {
|
|
|
46
47
|
return;
|
|
47
48
|
}
|
|
48
49
|
this.shouldReconnect = true;
|
|
49
|
-
this.reconnectAttempts = 0;
|
|
50
50
|
await this.connect();
|
|
51
51
|
log.info('connection', `Started connection`);
|
|
52
52
|
}
|
|
@@ -71,12 +71,25 @@ export class ConnectionManager extends EventEmitter {
|
|
|
71
71
|
try {
|
|
72
72
|
// Build WebSocket URL with access_token query parameter (NapCat OneBot 11 standard)
|
|
73
73
|
let wsUrl = this.config.wsUrl;
|
|
74
|
+
// Validate URL format before processing
|
|
75
|
+
if (!wsUrl || !wsUrl.match(/^wss?:\/\//)) {
|
|
76
|
+
log.error('connection', `Invalid WebSocket URL: ${wsUrl}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
74
79
|
if (this.config.accessToken) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(wsUrl);
|
|
82
|
+
url.searchParams.set('access_token', this.config.accessToken);
|
|
83
|
+
wsUrl = url.toString();
|
|
84
|
+
}
|
|
85
|
+
catch (urlError) {
|
|
86
|
+
log.error('connection', `Failed to parse WebSocket URL: ${wsUrl}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
78
89
|
}
|
|
79
|
-
|
|
90
|
+
// Sanitize URL for logging (hide access token)
|
|
91
|
+
const sanitizedUrl = wsUrl.replace(/access_token=[^&]+/, 'access_token=***');
|
|
92
|
+
log.info('connection', `Connecting to ${sanitizedUrl}`);
|
|
80
93
|
this.ws = new WebSocket(wsUrl);
|
|
81
94
|
this.ws.on('open', this.handleOpen.bind(this));
|
|
82
95
|
this.ws.on('message', this.handleMessage.bind(this));
|
|
@@ -102,10 +115,75 @@ export class ConnectionManager extends EventEmitter {
|
|
|
102
115
|
this.handleConnectionFailed(error instanceof Error ? error : new Error(String(error)));
|
|
103
116
|
}
|
|
104
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Clear all pending requests and reject them with an error
|
|
120
|
+
*/
|
|
121
|
+
clearPendingRequests(reason) {
|
|
122
|
+
if (this.pendingRequests.size === 0) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
log.debug('connection', `Clearing ${this.pendingRequests.size} pending requests: ${reason}`);
|
|
126
|
+
for (const [_echo, pending] of this.pendingRequests) {
|
|
127
|
+
clearTimeout(pending.timeout);
|
|
128
|
+
pending.reject(new Error(`Connection closed: ${reason}`));
|
|
129
|
+
}
|
|
130
|
+
this.pendingRequests.clear();
|
|
131
|
+
}
|
|
132
|
+
// ==========================================================================
|
|
133
|
+
// Heartbeat Timeout Detection
|
|
134
|
+
// ==========================================================================
|
|
135
|
+
/**
|
|
136
|
+
* Start heartbeat timeout detection
|
|
137
|
+
*/
|
|
138
|
+
startHeartbeatCheck() {
|
|
139
|
+
this.stopHeartbeatCheck();
|
|
140
|
+
this.heartbeatCheckTimer = setInterval(() => {
|
|
141
|
+
const elapsed = Date.now() - this.lastHeartbeatTime;
|
|
142
|
+
if (elapsed > HEARTBEAT_TIMEOUT && this.isConnected()) {
|
|
143
|
+
log.warn('connection', `Heartbeat timeout (${elapsed}ms since last heartbeat), reconnecting...`);
|
|
144
|
+
this.healthStatus = {
|
|
145
|
+
healthy: false,
|
|
146
|
+
lastHeartbeatAt: this.lastHeartbeatTime,
|
|
147
|
+
consecutiveFailures: this.healthStatus.consecutiveFailures + 1,
|
|
148
|
+
};
|
|
149
|
+
this.emit('heartbeat', this.healthStatus);
|
|
150
|
+
// Close connection and trigger immediate reconnect
|
|
151
|
+
this.close('Heartbeat timeout').then(() => {
|
|
152
|
+
if (this.shouldReconnect) {
|
|
153
|
+
// Increment total reconnect attempts
|
|
154
|
+
this.totalReconnectAttempts++;
|
|
155
|
+
// Emit reconnecting event for external status updates
|
|
156
|
+
this.emit('reconnecting', {
|
|
157
|
+
reason: 'heartbeat-timeout',
|
|
158
|
+
totalAttempts: this.totalReconnectAttempts,
|
|
159
|
+
});
|
|
160
|
+
this.connect().catch(error => {
|
|
161
|
+
log.error('connection', `Reconnect failed:`, error);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}, HEARTBEAT_CHECK_INTERVAL);
|
|
167
|
+
log.debug('connection', 'Started heartbeat timeout detection');
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Stop heartbeat timeout detection
|
|
171
|
+
*/
|
|
172
|
+
stopHeartbeatCheck() {
|
|
173
|
+
if (this.heartbeatCheckTimer) {
|
|
174
|
+
clearInterval(this.heartbeatCheckTimer);
|
|
175
|
+
this.heartbeatCheckTimer = undefined;
|
|
176
|
+
log.debug('connection', 'Stopped heartbeat timeout detection');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
105
179
|
/**
|
|
106
180
|
* Close WebSocket connection
|
|
107
181
|
*/
|
|
108
182
|
async close(reason) {
|
|
183
|
+
// Stop heartbeat detection
|
|
184
|
+
this.stopHeartbeatCheck();
|
|
185
|
+
// Clear all pending requests before closing connection
|
|
186
|
+
this.clearPendingRequests(reason);
|
|
109
187
|
if (this.ws) {
|
|
110
188
|
log.info('connection', `Closing connection: ${reason}`);
|
|
111
189
|
// Clear event listeners to prevent further processing
|
|
@@ -122,8 +200,8 @@ export class ConnectionManager extends EventEmitter {
|
|
|
122
200
|
handleOpen() {
|
|
123
201
|
log.info('connection', `Connected to NapCat`);
|
|
124
202
|
this.setState('connected');
|
|
125
|
-
|
|
126
|
-
this.
|
|
203
|
+
// Start heartbeat timeout detection
|
|
204
|
+
this.startHeartbeatCheck();
|
|
127
205
|
this.emit('connected');
|
|
128
206
|
}
|
|
129
207
|
handleMessage(data) {
|
|
@@ -220,17 +298,17 @@ export class ConnectionManager extends EventEmitter {
|
|
|
220
298
|
if (!this.shouldReconnect) {
|
|
221
299
|
return;
|
|
222
300
|
}
|
|
223
|
-
if (MAX_RECONNECT_ATTEMPTS != -1 && this.
|
|
301
|
+
if (MAX_RECONNECT_ATTEMPTS != -1 && this.totalReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
224
302
|
log.error('connection', `Max reconnect attempts reached`);
|
|
225
303
|
this.setState('failed', 'Max reconnect attempts reached');
|
|
226
304
|
this.emit('max-reconnect-attempts-reached');
|
|
227
305
|
return;
|
|
228
306
|
}
|
|
229
|
-
const delayMs = calculateBackoff(this.
|
|
230
|
-
log.info('connection', `Scheduling reconnect in ${delayMs}ms (attempt ${this.
|
|
307
|
+
const delayMs = calculateBackoff(this.totalReconnectAttempts);
|
|
308
|
+
log.info('connection', `Scheduling reconnect in ${delayMs}ms (attempt ${this.totalReconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
231
309
|
this.clearReconnectTimer();
|
|
232
310
|
this.reconnectTimer = setTimeout(async () => {
|
|
233
|
-
this.
|
|
311
|
+
this.totalReconnectAttempts++;
|
|
234
312
|
try {
|
|
235
313
|
await this.connect();
|
|
236
314
|
}
|
|
@@ -304,9 +382,9 @@ export class ConnectionManager extends EventEmitter {
|
|
|
304
382
|
return {
|
|
305
383
|
state: this.state,
|
|
306
384
|
lastConnected: this.lastHeartbeatTime || undefined,
|
|
307
|
-
lastAttempted: this.
|
|
385
|
+
lastAttempted: this.totalReconnectAttempts > 0 ? Date.now() : undefined,
|
|
308
386
|
error: this.state === 'failed' ? 'Connection failed' : undefined,
|
|
309
|
-
reconnectAttempts: this.
|
|
387
|
+
reconnectAttempts: this.totalReconnectAttempts > 0 ? this.totalReconnectAttempts : undefined,
|
|
310
388
|
};
|
|
311
389
|
}
|
|
312
390
|
// ==========================================================================
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message Dispatch Module
|
|
3
3
|
* Handles routing and dispatching incoming messages to the AI
|
|
4
4
|
*/
|
|
5
|
-
import type { DispatchMessageParams } from '../types
|
|
5
|
+
import type { DispatchMessageParams } from '../types';
|
|
6
6
|
/**
|
|
7
7
|
* Dispatch an incoming message to the AI for processing
|
|
8
8
|
*/
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { getRuntime, getContext } from './runtime.js';
|
|
6
6
|
import { getFile, sendMsg, setInputStatus } from './request.js';
|
|
7
|
-
import { napCatToOpenClawMessage } from '../adapters/message.js';
|
|
8
|
-
import { Logger as log, markdownToText } from '../utils/index.js';
|
|
7
|
+
import { napCatToOpenClawMessage, openClawToNapCatMessage } from '../adapters/message.js';
|
|
8
|
+
import { Logger as log, markdownToText, buildMediaMessage } from '../utils/index.js';
|
|
9
9
|
import { CHANNEL_ID } from "./config.js";
|
|
10
10
|
/**
|
|
11
11
|
* Convert OpenClaw message content array to plain text
|
|
@@ -24,7 +24,9 @@ async function contentToPlainText(content) {
|
|
|
24
24
|
case 'json':
|
|
25
25
|
return `[JSON]\n\`\`\`json\n${c.data}\n\`\`\``;
|
|
26
26
|
case 'reply':
|
|
27
|
-
|
|
27
|
+
const senderInfo = c.sender && c.senderId ? `${c.sender}(${c.senderId})` : '未知用户';
|
|
28
|
+
const replyMsg = c.message ?? '[无法获取原消息]';
|
|
29
|
+
let replyContent = `${senderInfo}:\n${replyMsg}`;
|
|
28
30
|
replyContent = replyContent.split('\n').map(line => `> ${line}`).join('\n');
|
|
29
31
|
return `[回复]\n${replyContent}\n`;
|
|
30
32
|
default:
|
|
@@ -83,6 +85,21 @@ async function sendText(isGroup, chatId, text) {
|
|
|
83
85
|
log.error('dispatch', `Send failed: ${error}`);
|
|
84
86
|
}
|
|
85
87
|
}
|
|
88
|
+
async function sendMedia(isGroup, chatId, mediaUrl) {
|
|
89
|
+
const content = [buildMediaMessage(mediaUrl)];
|
|
90
|
+
try {
|
|
91
|
+
await sendMsg({
|
|
92
|
+
message_type: isGroup ? 'group' : 'private',
|
|
93
|
+
group_id: isGroup ? chatId : undefined,
|
|
94
|
+
user_id: !isGroup ? chatId : undefined,
|
|
95
|
+
message: openClawToNapCatMessage(content),
|
|
96
|
+
});
|
|
97
|
+
log.info('dispatch', `Sent reply: ${mediaUrl.slice(0, 100)}`);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
log.error('dispatch', `Send failed: ${error}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
86
103
|
/**
|
|
87
104
|
* Dispatch an incoming message to the AI for processing
|
|
88
105
|
*/
|
|
@@ -168,9 +185,20 @@ export async function dispatchMessage(params) {
|
|
|
168
185
|
},
|
|
169
186
|
deliver: async (payload, info) => {
|
|
170
187
|
log.info('dispatch', `deliver(${info.kind}): ${JSON.stringify(payload)}`);
|
|
171
|
-
if (payload.text) {
|
|
188
|
+
if (payload.text && !payload.text.startsWith('MEDIA:')) {
|
|
172
189
|
await sendText(isGroup, chatId, payload.text);
|
|
173
190
|
}
|
|
191
|
+
if (payload.text && payload.text.startsWith('MEDIA:')) {
|
|
192
|
+
await sendMedia(isGroup, chatId, payload.text.replace('MEDIA:', ''));
|
|
193
|
+
}
|
|
194
|
+
if (payload.mediaUrl) {
|
|
195
|
+
await sendMedia(isGroup, chatId, payload.mediaUrl);
|
|
196
|
+
}
|
|
197
|
+
if (payload.mediaUrls && payload.mediaUrls.length > 0) {
|
|
198
|
+
for (const mediaUrl of payload.mediaUrls) {
|
|
199
|
+
await sendMedia(isGroup, chatId, mediaUrl);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
174
202
|
},
|
|
175
203
|
onError: async (err) => {
|
|
176
204
|
log.error('dispatch', `Dispatch error: ${err}`);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { GetFileReq, GetFileResp, GetMsgReq, GetMsgResp, NapCatResp, SendMsgReq, SendMsgResp, SetInputStatusReq } from "../types
|
|
1
|
+
import type { GetFileReq, GetFileResp, GetMsgReq, GetMsgResp, GetStatusResp, NapCatResp, SendMsgReq, SendMsgResp, SetInputStatusReq } from "../types";
|
|
2
2
|
/**
|
|
3
3
|
* 事件监听
|
|
4
4
|
* @param event
|
|
5
5
|
*/
|
|
6
6
|
export declare function eventListener(event: any): Promise<void>;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* 发送消息(带限流)
|
|
9
9
|
* @param params
|
|
10
10
|
*/
|
|
11
11
|
export declare function sendMsg(params: SendMsgReq): Promise<NapCatResp<SendMsgResp>>;
|
|
@@ -24,3 +24,7 @@ export declare function getFile(params: GetFileReq): Promise<NapCatResp<GetFileR
|
|
|
24
24
|
* @param params
|
|
25
25
|
*/
|
|
26
26
|
export declare function setInputStatus(params: SetInputStatusReq): Promise<NapCatResp<void>>;
|
|
27
|
+
/**
|
|
28
|
+
* 获取状态
|
|
29
|
+
*/
|
|
30
|
+
export declare function getStatus(): Promise<NapCatResp<GetStatusResp>>;
|
package/dist/src/core/request.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import pLimit from 'p-limit';
|
|
1
2
|
import { Logger as log } from "../utils/index.js";
|
|
2
3
|
import { setContextStatus, getContext, getConnection } from "./runtime.js";
|
|
3
4
|
import { handleGroupMessage, handlePrivateMessage, handlePokeEvent } from "./dispatch.js";
|
|
4
5
|
import { failResp } from "./connection.js";
|
|
6
|
+
/**
|
|
7
|
+
* Rate limiter for sendMsg requests
|
|
8
|
+
* Limits concurrent messages to prevent API throttling
|
|
9
|
+
*/
|
|
10
|
+
const sendMsgLimiter = pLimit(1);
|
|
5
11
|
/**
|
|
6
12
|
* 事件监听
|
|
7
13
|
* @param event
|
|
@@ -20,6 +26,11 @@ export async function eventListener(event) {
|
|
|
20
26
|
}
|
|
21
27
|
switch (event.post_type) {
|
|
22
28
|
case "message":
|
|
29
|
+
// 过滤空消息
|
|
30
|
+
if (!event.raw_message || event.raw_message === '') {
|
|
31
|
+
log.debug("request", `Ignored empty message`);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
23
34
|
setContextStatus({
|
|
24
35
|
lastInboundAt: Date.now(),
|
|
25
36
|
});
|
|
@@ -66,7 +77,7 @@ export async function eventListener(event) {
|
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
79
|
/**
|
|
69
|
-
*
|
|
80
|
+
* 发送消息(带限流)
|
|
70
81
|
* @param params
|
|
71
82
|
*/
|
|
72
83
|
export async function sendMsg(params) {
|
|
@@ -75,7 +86,8 @@ export async function sendMsg(params) {
|
|
|
75
86
|
log.warn("request", `No connection available`);
|
|
76
87
|
return failResp();
|
|
77
88
|
}
|
|
78
|
-
|
|
89
|
+
// 使用限流器控制并发,避免触发 NapCat API 限流
|
|
90
|
+
return sendMsgLimiter(() => connection.sendRequest("send_msg", params));
|
|
79
91
|
}
|
|
80
92
|
/**
|
|
81
93
|
* 获取消息
|
|
@@ -113,3 +125,14 @@ export async function setInputStatus(params) {
|
|
|
113
125
|
}
|
|
114
126
|
return connection.sendRequest("set_input_status", params);
|
|
115
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* 获取状态
|
|
130
|
+
*/
|
|
131
|
+
export async function getStatus() {
|
|
132
|
+
const connection = getConnection();
|
|
133
|
+
if (!connection) {
|
|
134
|
+
log.warn("request", `No connection available`);
|
|
135
|
+
return failResp();
|
|
136
|
+
}
|
|
137
|
+
return connection.sendRequest("get_status");
|
|
138
|
+
}
|
|
@@ -243,6 +243,11 @@ export interface SetInputStatusReq {
|
|
|
243
243
|
user_id: string;
|
|
244
244
|
event_type: 0 | 1 | 2;
|
|
245
245
|
}
|
|
246
|
+
export interface GetStatusResp {
|
|
247
|
+
online: boolean;
|
|
248
|
+
good: boolean;
|
|
249
|
+
stat: Record<any, any>;
|
|
250
|
+
}
|
|
246
251
|
export interface DispatchMessageMedia {
|
|
247
252
|
type?: string;
|
|
248
253
|
path?: string;
|
|
@@ -258,3 +263,8 @@ export interface DispatchMessageParams {
|
|
|
258
263
|
media?: DispatchMessageMedia;
|
|
259
264
|
timestamp: number;
|
|
260
265
|
}
|
|
266
|
+
export type QQProbe = {
|
|
267
|
+
ok: boolean;
|
|
268
|
+
status?: number | null;
|
|
269
|
+
error?: string | null;
|
|
270
|
+
};
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Utility functions for QQ NapCat plugin
|
|
3
3
|
*/
|
|
4
|
+
import type { OpenClawMessage } from '../types';
|
|
4
5
|
/**
|
|
5
6
|
* Generate a unique message ID for OpenClaw
|
|
7
|
+
* Uses UUID for thread-safe and collision-free ID generation
|
|
6
8
|
*/
|
|
7
9
|
export declare function generateMessageId(): string;
|
|
8
10
|
/**
|
|
9
11
|
* Generate a unique echo ID for request correlation
|
|
12
|
+
* Uses UUID for thread-safe and collision-free ID generation
|
|
10
13
|
*/
|
|
11
14
|
export declare function generateEchoId(): string;
|
|
12
15
|
/**
|
|
@@ -49,6 +52,11 @@ export declare function getCloseCodeMessage(code: number): string;
|
|
|
49
52
|
export type FileCategory = 'image' | 'audio' | 'file';
|
|
50
53
|
export declare function getFileType(pathOrUrl: string): FileCategory;
|
|
51
54
|
export declare function getFileName(pathOrUrl: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Build an OpenClawMessage from a media URL
|
|
57
|
+
* Automatically detects file type (image, audio, or file)
|
|
58
|
+
*/
|
|
59
|
+
export declare function buildMediaMessage(mediaUrl: string): OpenClawMessage;
|
|
52
60
|
export { CQCodeUtils, CQNode } from './cqcode.js';
|
|
53
61
|
export { Logger } from './log.js';
|
|
54
62
|
export { MarkdownToText, markdownToText, } from './markdown.js';
|
package/dist/src/utils/index.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Utility functions for QQ NapCat plugin
|
|
3
3
|
*/
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
4
5
|
// =============================================================================
|
|
5
6
|
// ID Generation
|
|
6
7
|
// =============================================================================
|
|
7
|
-
let idCounter = 0;
|
|
8
8
|
/**
|
|
9
9
|
* Generate a unique message ID for OpenClaw
|
|
10
|
+
* Uses UUID for thread-safe and collision-free ID generation
|
|
10
11
|
*/
|
|
11
12
|
export function generateMessageId() {
|
|
12
|
-
return `qq-${
|
|
13
|
+
return `qq-${randomUUID()}`;
|
|
13
14
|
}
|
|
14
15
|
/**
|
|
15
16
|
* Generate a unique echo ID for request correlation
|
|
17
|
+
* Uses UUID for thread-safe and collision-free ID generation
|
|
16
18
|
*/
|
|
17
19
|
export function generateEchoId() {
|
|
18
|
-
return `echo-${
|
|
20
|
+
return `echo-${randomUUID()}`;
|
|
19
21
|
}
|
|
20
22
|
// =============================================================================
|
|
21
23
|
// Message ID Conversion
|
|
@@ -296,6 +298,33 @@ export function getFileName(pathOrUrl) {
|
|
|
296
298
|
}
|
|
297
299
|
}
|
|
298
300
|
// =============================================================================
|
|
301
|
+
// Media Message Builder
|
|
302
|
+
// =============================================================================
|
|
303
|
+
/**
|
|
304
|
+
* Build an OpenClawMessage from a media URL
|
|
305
|
+
* Automatically detects file type (image, audio, or file)
|
|
306
|
+
*/
|
|
307
|
+
export function buildMediaMessage(mediaUrl) {
|
|
308
|
+
const trimmedUrl = mediaUrl.trim();
|
|
309
|
+
switch (getFileType(trimmedUrl)) {
|
|
310
|
+
case "image":
|
|
311
|
+
return { type: "image", url: trimmedUrl };
|
|
312
|
+
case "audio":
|
|
313
|
+
return {
|
|
314
|
+
type: "audio",
|
|
315
|
+
path: trimmedUrl,
|
|
316
|
+
url: trimmedUrl,
|
|
317
|
+
file: getFileName(trimmedUrl)
|
|
318
|
+
};
|
|
319
|
+
default:
|
|
320
|
+
return {
|
|
321
|
+
type: "file",
|
|
322
|
+
url: trimmedUrl,
|
|
323
|
+
file: getFileName(trimmedUrl)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// =============================================================================
|
|
299
328
|
// CQ Code Utilities
|
|
300
329
|
// =============================================================================
|
|
301
330
|
export { CQCodeUtils } from './cqcode.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@izhimu/qq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A QQ channel plugin for OpenClaw using NapCat WebSocket",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
+
"p-limit": "^7.3.0",
|
|
52
53
|
"ws": "^8.19.0",
|
|
53
54
|
"zod": "^4.3.6"
|
|
54
55
|
},
|