@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 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,67 @@ 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
+ 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: CHANNEL_ID,
78
- enabled: account.enabled,
79
- configured: Boolean(account.wsUrl),
80
- ...runtime,
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
- ctx.setStatus({
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
- 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
- }
229
+ content.push(buildMediaMessage(mediaUrl));
155
230
  }
156
231
  if (replyToId) {
157
232
  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 = 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
- // 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
  }
@@ -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
- const url = new URL(wsUrl);
76
- url.searchParams.set('access_token', this.config.accessToken);
77
- wsUrl = url.toString();
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
- log.info('connection', `Connecting to ${wsUrl}`);
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
- this.totalReconnectAttempts += this.reconnectAttempts;
126
- this.reconnectAttempts = 0;
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.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
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.reconnectAttempts);
230
- log.info('connection', `Scheduling reconnect in ${delayMs}ms (attempt ${this.reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
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.reconnectAttempts++;
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.reconnectAttempts > 0 ? Date.now() : undefined,
385
+ lastAttempted: this.totalReconnectAttempts > 0 ? Date.now() : undefined,
308
386
  error: this.state === 'failed' ? 'Connection failed' : undefined,
309
- reconnectAttempts: this.reconnectAttempts > 0 ? this.reconnectAttempts : undefined,
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/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
+ }
@@ -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.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
  },