@izhimu/qq 0.2.3 → 0.3.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 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/)
@@ -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/index.js";
5
+ import type { ChannelPlugin } from "openclaw/plugin-sdk";
6
+ import type { QQConfig } from "./types";
7
7
  export declare const qqPlugin: ChannelPlugin<QQConfig>;
@@ -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 { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
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,66 @@ export const qqPlugin = {
71
70
  sendMedia: outboundSend,
72
71
  },
73
72
  status: {
74
- buildAccountSnapshot: ({ account, runtime }) => {
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
+ lastProbeAt: Date.now(),
109
+ });
110
+ return {
111
+ ok: status.status === "ok",
112
+ status: status.retcode,
113
+ error: status.status === "failed" ? status.msg : null,
114
+ };
115
+ },
116
+ buildAccountSnapshot: ({ account, runtime, probe }) => {
75
117
  return {
76
118
  accountId: DEFAULT_ACCOUNT_ID,
77
- name: CHANNEL_ID,
78
- enabled: account.enabled,
79
- configured: Boolean(account.wsUrl),
80
- ...runtime,
119
+ name: "QQ",
120
+ enabled: account.enabled ?? false,
121
+ configured: Boolean(account.wsUrl?.trim()),
122
+ linked: runtime?.linked ?? false,
123
+ running: runtime?.running ?? false,
124
+ connected: runtime?.connected ?? false,
125
+ reconnectAttempts: runtime?.reconnectAttempts ?? 0,
126
+ lastStartAt: runtime?.lastStartAt ?? null,
127
+ lastStopAt: runtime?.lastStopAt ?? null,
128
+ lastError: runtime?.lastError ?? null,
129
+ lastInboundAt: runtime?.lastInboundAt ?? null,
130
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
131
+ probe,
132
+ lastProbeAt: runtime?.lastProbeAt ?? null,
81
133
  };
82
134
  },
83
135
  },
@@ -86,12 +138,11 @@ export const qqPlugin = {
86
138
  setContext(ctx);
87
139
  const { account } = ctx;
88
140
  log.info('gateway', `Starting gateway`);
89
- // Update start time
90
- ctx.setStatus({
91
- ...ctx.getStatus(),
92
- running: true,
93
- lastStartAt: Date.now(),
94
- });
141
+ // 检查是否已存在连接
142
+ const existingConnection = getConnection();
143
+ if (existingConnection) {
144
+ await stopAccount();
145
+ }
95
146
  // Create new connection manager
96
147
  const connection = new ConnectionManager(account);
97
148
  connection.on("event", (event) => eventListener(event));
@@ -99,36 +150,68 @@ export const qqPlugin = {
99
150
  log.info('gateway', `State: ${status.state}`);
100
151
  if (status.state === "connected") {
101
152
  setContextStatus({
153
+ linked: true,
102
154
  connected: true,
103
155
  lastConnectedAt: Date.now(),
104
156
  });
105
157
  }
106
158
  else if (status.state === "disconnected" || status.state === "failed") {
107
159
  setContextStatus({
160
+ linked: false,
108
161
  connected: false,
109
162
  lastError: status.error,
110
163
  });
111
164
  }
112
165
  });
113
- await connection.start();
114
- setConnection(connection);
115
- log.info('gateway', `Started gateway`);
116
- },
117
- stopAccount: async (_ctx) => {
118
- const connection = getConnection();
119
- if (connection) {
120
- await connection.stop();
121
- clearConnection();
122
- }
123
- setContextStatus({
124
- running: false,
125
- connected: false,
126
- lastStopAt: Date.now(),
166
+ connection.on("reconnecting", (info) => {
167
+ log.info('gateway', `Reconnecting: ${info.reason}, attempt ${info.totalAttempts}`);
168
+ setContextStatus({
169
+ linked: false,
170
+ connected: false,
171
+ lastError: `Reconnecting (${info.reason})`,
172
+ reconnectAttempts: info.totalAttempts,
173
+ });
127
174
  });
128
- clearContext();
175
+ try {
176
+ await connection.start();
177
+ setConnection(connection);
178
+ // Update start time
179
+ setContextStatus({
180
+ running: true,
181
+ linked: true,
182
+ connected: true,
183
+ lastStartAt: Date.now(),
184
+ });
185
+ log.info('gateway', `Started gateway`);
186
+ }
187
+ catch (error) {
188
+ log.error('gateway', `Failed to start gateway:`, error);
189
+ setContextStatus({
190
+ running: false,
191
+ linked: false,
192
+ connected: false,
193
+ lastError: error instanceof Error ? error.message : 'Failed to start gateway',
194
+ });
195
+ throw error;
196
+ }
129
197
  },
130
- },
198
+ stopAccount,
199
+ }
131
200
  };
201
+ async function stopAccount() {
202
+ const connection = getConnection();
203
+ if (connection) {
204
+ await connection.stop();
205
+ clearConnection();
206
+ }
207
+ setContextStatus({
208
+ running: false,
209
+ linked: false,
210
+ connected: false,
211
+ lastStopAt: Date.now(),
212
+ });
213
+ clearContext();
214
+ }
132
215
  async function outboundSend(ctx) {
133
216
  const { to, text, mediaUrl, accountId, replyToId } = ctx;
134
217
  log.debug("outbound", `send called - accountId: ${accountId}, to: ${to}, mediaUrl: ${mediaUrl ?? "null"}, replyToId: ${replyToId ?? "none"}`);
@@ -142,16 +225,7 @@ async function outboundSend(ctx) {
142
225
  content.push({ type: "text", text: markdownToText(text) });
143
226
  }
144
227
  if (mediaUrl) {
145
- switch (getFileType(mediaUrl)) {
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
- }
228
+ content.push(buildMediaMessage(mediaUrl));
155
229
  }
156
230
  if (replyToId) {
157
231
  content.push({ type: "reply", messageId: replyToId });
@@ -2,7 +2,7 @@
2
2
  * QQ 配置管理
3
3
  */
4
4
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
5
- import type { QQConfig } from "../types/index.js";
5
+ import type { QQConfig } from "../types";
6
6
  import { z } from "zod";
7
7
  export declare const CHANNEL_ID = "qq";
8
8
  /**
@@ -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: z.string().default("ws://127.0.0.1:3001"),
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/index.js';
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 totalReconnectAttempts;
15
+ private heartbeatCheckTimer?;
16
16
  private reconnectTimer?;
17
- private reconnectAttempts;
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 = 120000; // 120 seconds - time without heartbeat before reconnecting (increased for NapCat compatibility)
11
+ const HEARTBEAT_CHECK_INTERVAL = 60000; // 60 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
- // Connection stats
20
- totalReconnectAttempts = 0;
21
+ heartbeatCheckTimer;
21
22
  // Reconnection
22
23
  reconnectTimer;
23
- reconnectAttempts = 0;
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
  }
@@ -67,16 +67,34 @@ export class ConnectionManager extends EventEmitter {
67
67
  if (this.ws?.readyState === WebSocket.OPEN) {
68
68
  return;
69
69
  }
70
+ // 防御性清理:确保旧连接和监听器被清理,避免潜在的内存泄漏
71
+ if (this.ws) {
72
+ this.ws.removeAllListeners();
73
+ this.ws = null;
74
+ }
70
75
  this.setState('connecting');
71
76
  try {
72
77
  // Build WebSocket URL with access_token query parameter (NapCat OneBot 11 standard)
73
78
  let wsUrl = this.config.wsUrl;
79
+ // Validate URL format before processing
80
+ if (!wsUrl || !wsUrl.match(/^wss?:\/\//)) {
81
+ log.error('connection', `Invalid WebSocket URL: ${wsUrl}`);
82
+ return;
83
+ }
74
84
  if (this.config.accessToken) {
75
- const url = new URL(wsUrl);
76
- url.searchParams.set('access_token', this.config.accessToken);
77
- wsUrl = url.toString();
85
+ try {
86
+ const url = new URL(wsUrl);
87
+ url.searchParams.set('access_token', this.config.accessToken);
88
+ wsUrl = url.toString();
89
+ }
90
+ catch (urlError) {
91
+ log.error('connection', `Failed to parse WebSocket URL: ${wsUrl}`);
92
+ return;
93
+ }
78
94
  }
79
- log.info('connection', `Connecting to ${wsUrl}`);
95
+ // Sanitize URL for logging (hide access token)
96
+ const sanitizedUrl = wsUrl.replace(/access_token=[^&]+/, 'access_token=***');
97
+ log.info('connection', `Connecting to ${sanitizedUrl}`);
80
98
  this.ws = new WebSocket(wsUrl);
81
99
  this.ws.on('open', this.handleOpen.bind(this));
82
100
  this.ws.on('message', this.handleMessage.bind(this));
@@ -102,10 +120,76 @@ export class ConnectionManager extends EventEmitter {
102
120
  this.handleConnectionFailed(error instanceof Error ? error : new Error(String(error)));
103
121
  }
104
122
  }
123
+ /**
124
+ * Clear all pending requests and reject them with an error
125
+ */
126
+ clearPendingRequests(reason) {
127
+ if (this.pendingRequests.size === 0) {
128
+ return;
129
+ }
130
+ log.debug('connection', `Clearing ${this.pendingRequests.size} pending requests: ${reason}`);
131
+ for (const [_echo, pending] of this.pendingRequests) {
132
+ clearTimeout(pending.timeout);
133
+ pending.reject(new Error(`Connection closed: ${reason}`));
134
+ }
135
+ this.pendingRequests.clear();
136
+ }
137
+ // ==========================================================================
138
+ // Heartbeat Timeout Detection
139
+ // ==========================================================================
140
+ /**
141
+ * Start heartbeat timeout detection
142
+ */
143
+ startHeartbeatCheck() {
144
+ this.stopHeartbeatCheck();
145
+ this.heartbeatCheckTimer = setInterval(() => {
146
+ const elapsed = Date.now() - this.lastHeartbeatTime;
147
+ if (elapsed > HEARTBEAT_TIMEOUT && this.isConnected()) {
148
+ log.warn('connection', `Heartbeat timeout (${elapsed}ms since last heartbeat), reconnecting...`);
149
+ this.healthStatus = {
150
+ healthy: false,
151
+ lastHeartbeatAt: this.lastHeartbeatTime,
152
+ consecutiveFailures: this.healthStatus.consecutiveFailures + 1,
153
+ };
154
+ this.emit('heartbeat', this.healthStatus);
155
+ // Close connection and trigger immediate reconnect
156
+ this.setState('disconnected');
157
+ this.close('Heartbeat timeout').then(() => {
158
+ if (this.shouldReconnect) {
159
+ // Increment total reconnect attempts
160
+ this.totalReconnectAttempts++;
161
+ // Emit reconnecting event for external status updates
162
+ this.emit('reconnecting', {
163
+ reason: 'heartbeat-timeout',
164
+ totalAttempts: this.totalReconnectAttempts,
165
+ });
166
+ this.connect().catch(error => {
167
+ log.error('connection', `Reconnect failed:`, error);
168
+ });
169
+ }
170
+ });
171
+ }
172
+ }, HEARTBEAT_CHECK_INTERVAL);
173
+ log.debug('connection', 'Started heartbeat timeout detection');
174
+ }
175
+ /**
176
+ * Stop heartbeat timeout detection
177
+ */
178
+ stopHeartbeatCheck() {
179
+ if (this.heartbeatCheckTimer) {
180
+ clearInterval(this.heartbeatCheckTimer);
181
+ this.heartbeatCheckTimer = undefined;
182
+ log.debug('connection', 'Stopped heartbeat timeout detection');
183
+ }
184
+ }
105
185
  /**
106
186
  * Close WebSocket connection
107
187
  */
108
188
  async close(reason) {
189
+ // Stop heartbeat detection
190
+ this.stopHeartbeatCheck();
191
+ // Clear all pending requests before closing connection
192
+ this.clearPendingRequests(reason);
109
193
  if (this.ws) {
110
194
  log.info('connection', `Closing connection: ${reason}`);
111
195
  // Clear event listeners to prevent further processing
@@ -122,8 +206,8 @@ export class ConnectionManager extends EventEmitter {
122
206
  handleOpen() {
123
207
  log.info('connection', `Connected to NapCat`);
124
208
  this.setState('connected');
125
- this.totalReconnectAttempts += this.reconnectAttempts;
126
- this.reconnectAttempts = 0;
209
+ // Start heartbeat timeout detection
210
+ this.startHeartbeatCheck();
127
211
  this.emit('connected');
128
212
  }
129
213
  handleMessage(data) {
@@ -176,6 +260,12 @@ export class ConnectionManager extends EventEmitter {
176
260
  handleClose(code, reason) {
177
261
  const reasonStr = reason.toString() || getCloseCodeMessage(code);
178
262
  log.warn('connection', `Connection closed: ${code} - ${reasonStr}`);
263
+ // 停止心跳检测
264
+ this.stopHeartbeatCheck();
265
+ // 如果 ws 已经为 null,说明是主动关闭(如心跳超时),不需要再处理
266
+ if (this.ws === null) {
267
+ return;
268
+ }
179
269
  if (this.shouldReconnect && !this.isNormalClosure(code)) {
180
270
  this.scheduleReconnect();
181
271
  }
@@ -220,17 +310,17 @@ export class ConnectionManager extends EventEmitter {
220
310
  if (!this.shouldReconnect) {
221
311
  return;
222
312
  }
223
- if (MAX_RECONNECT_ATTEMPTS != -1 && this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
313
+ if (MAX_RECONNECT_ATTEMPTS != -1 && this.totalReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
224
314
  log.error('connection', `Max reconnect attempts reached`);
225
315
  this.setState('failed', 'Max reconnect attempts reached');
226
316
  this.emit('max-reconnect-attempts-reached');
227
317
  return;
228
318
  }
229
- const delayMs = calculateBackoff(this.reconnectAttempts);
230
- log.info('connection', `Scheduling reconnect in ${delayMs}ms (attempt ${this.reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
319
+ const delayMs = calculateBackoff(this.totalReconnectAttempts);
320
+ log.info('connection', `Scheduling reconnect in ${delayMs}ms (attempt ${this.totalReconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
231
321
  this.clearReconnectTimer();
232
322
  this.reconnectTimer = setTimeout(async () => {
233
- this.reconnectAttempts++;
323
+ this.totalReconnectAttempts++;
234
324
  try {
235
325
  await this.connect();
236
326
  }
@@ -304,9 +394,9 @@ export class ConnectionManager extends EventEmitter {
304
394
  return {
305
395
  state: this.state,
306
396
  lastConnected: this.lastHeartbeatTime || undefined,
307
- lastAttempted: this.reconnectAttempts > 0 ? Date.now() : undefined,
397
+ lastAttempted: this.totalReconnectAttempts > 0 ? Date.now() : undefined,
308
398
  error: this.state === 'failed' ? 'Connection failed' : undefined,
309
- reconnectAttempts: this.reconnectAttempts > 0 ? this.reconnectAttempts : undefined,
399
+ reconnectAttempts: this.totalReconnectAttempts > 0 ? this.totalReconnectAttempts : undefined,
310
400
  };
311
401
  }
312
402
  // ==========================================================================
@@ -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/index.js';
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
- let replyContent = `${c.sender}(${c.senderId}):\n${c.message}`;
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/index.js";
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>>;
@@ -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
- return connection.sendRequest("send_msg", params);
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
+ }
@@ -3,7 +3,7 @@
3
3
  * Stores the PluginRuntime for access in gateway handlers
4
4
  */
5
5
  import type { ChannelAccountSnapshot, ChannelGatewayContext, PluginRuntime } from "openclaw/plugin-sdk";
6
- import type { QQConfig } from "../types/index.js";
6
+ import type { QQConfig } from "../types";
7
7
  import { ConnectionManager } from "./connection.js";
8
8
  export declare function setRuntime(next: PluginRuntime): void;
9
9
  export declare function getRuntime(): PluginRuntime | null;
@@ -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';
@@ -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-${Date.now()}-${++idCounter}`;
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-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
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.2.3",
3
+ "version": "0.3.1",
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
  },