@izhimu/qq 0.3.2 → 0.4.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
@@ -27,7 +27,7 @@
27
27
  </p>
28
28
 
29
29
  ---
30
-
30
+ ![demo.png](docs/demo.png)
31
31
  ## 目录
32
32
 
33
33
  - [功能特性](#功能特性)
@@ -67,6 +67,9 @@
67
67
  ```bash
68
68
  # 安装插件
69
69
  openclaw plugins install @izhimu/qq
70
+
71
+ # 更新插件
72
+ openclaw plugins update @izhimu/qq
70
73
  ```
71
74
 
72
75
  ### 本地开发安装
@@ -371,6 +374,17 @@ npm run build
371
374
 
372
375
  ## 更新日志
373
376
 
377
+ ### [0.4.0] - 2026-03-07
378
+
379
+ #### 新增
380
+ - 群 At 模式(`groupAtMode`)- 开启后只有被 @ 或 @全体成员 时才回复
381
+ - 登录信息存储功能,获取并保存当前登录 QQ 号
382
+ - 消息中断处理机制,新消息到来时正确终止上一条消息的回复
383
+
384
+ #### 修复
385
+ - 修复 abort 后 deliver 仍然发送已终止消息的问题
386
+ - 修复 abortController 状态检查不准确的问题(使用独立 aborted 标志)
387
+
374
388
  ### [0.3.0] - 2026-02-12
375
389
 
376
390
  #### 新增
@@ -3,6 +3,6 @@
3
3
  *
4
4
  * Optimized for maintainability with clear structure and minimal duplication.
5
5
  */
6
- import type { NapCatMessage, OpenClawMessage } from '../types/index.js';
6
+ import type { NapCatMessage, OpenClawMessage } from '../types';
7
7
  export declare function openClawToNapCatMessage(content: OpenClawMessage[]): NapCatMessage[];
8
8
  export declare function napCatToOpenClawMessage(segments: NapCatMessage[] | string): Promise<OpenClawMessage[]>;
@@ -4,7 +4,7 @@
4
4
  * Optimized for maintainability with clear structure and minimal duplication.
5
5
  */
6
6
  import { Logger as log, extractImageUrl, getEmojiForFaceId } from '../utils/index.js';
7
- import { CQCodeUtils } from '../utils/cqcode.js';
7
+ import { CQCodeUtils } from '../utils';
8
8
  import { getMsg } from "../core/request.js";
9
9
  // =============================================================================
10
10
  // CQ Code Parsing
@@ -94,6 +94,12 @@ async function napCatToOpenClaw(segment) {
94
94
  senderId: String(response.data.sender.user_id),
95
95
  sender: response.data.sender.nickname
96
96
  };
97
+ case 'video':
98
+ return {
99
+ type: 'video',
100
+ url: String(data.url || ''),
101
+ fileSize: data.file_size ? parseInt(String(data.file_size), 10) : undefined,
102
+ };
97
103
  case 'face':
98
104
  return { type: 'text', text: getEmojiForFaceId(String(data.id || '')) };
99
105
  case 'record':
@@ -2,13 +2,13 @@
2
2
  * QQ NapCat Plugin for OpenClaw
3
3
  * Main plugin entry point
4
4
  */
5
- import { buildChannelConfigSchema, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
5
+ import { DEFAULT_ACCOUNT_ID, buildChannelConfigSchema, setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from "openclaw/plugin-sdk";
6
6
  import { messageIdToString, markdownToText, buildMediaMessage, Logger as log } from "./utils/index.js";
7
- import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection } from "./core/runtime.js";
7
+ import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection, setLoginInfo } from "./core/runtime.js";
8
8
  import { ConnectionManager } from "./core/connection.js";
9
9
  import { openClawToNapCatMessage } from "./adapters/message.js";
10
10
  import { listQQAccountIds, resolveQQAccount, QQConfigSchema, CHANNEL_ID } from "./core/config.js";
11
- import { eventListener, sendMsg, getStatus } from "./core/request.js";
11
+ import { eventListener, sendMsg, getStatus, getLoginInfo, getFriendList, getGroupList } from "./core/request.js";
12
12
  import { qqOnboardingAdapter } from "./onboarding.js";
13
13
  export const qqPlugin = {
14
14
  id: CHANNEL_ID,
@@ -16,7 +16,7 @@ export const qqPlugin = {
16
16
  id: CHANNEL_ID,
17
17
  label: "QQ",
18
18
  selectionLabel: "QQ",
19
- docsPath: "/channels/qq",
19
+ docsPath: "extensions/qq",
20
20
  blurb: "通过 NapCat WebSocket 连接 QQ 机器人",
21
21
  quickstartAllowFrom: true,
22
22
  },
@@ -104,11 +104,14 @@ export const qqPlugin = {
104
104
  }),
105
105
  probeAccount: async () => {
106
106
  const status = await getStatus();
107
+ const ok = status.status === "ok";
107
108
  setContextStatus({
109
+ linked: ok,
110
+ running: ok,
108
111
  lastProbeAt: Date.now(),
109
112
  });
110
113
  return {
111
- ok: status.status === "ok",
114
+ ok: ok,
112
115
  status: status.retcode,
113
116
  error: status.status === "failed" ? status.msg : null,
114
117
  };
@@ -176,6 +179,14 @@ export const qqPlugin = {
176
179
  try {
177
180
  await connection.start();
178
181
  setConnection(connection);
182
+ // 获取登录信息
183
+ const info = await getLoginInfo();
184
+ if (info.data) {
185
+ setLoginInfo({
186
+ userId: info.data.user_id.toString(),
187
+ nickname: info.data.nickname,
188
+ });
189
+ }
179
190
  // Update start time
180
191
  setContextStatus({
181
192
  running: true,
@@ -210,6 +221,24 @@ export const qqPlugin = {
210
221
  });
211
222
  clearContext();
212
223
  },
224
+ },
225
+ directory: {
226
+ self: async () => {
227
+ const info = await getLoginInfo();
228
+ if (!info.data) {
229
+ return null;
230
+ }
231
+ log.debug('directory', `self: ${JSON.stringify(info.data)}`);
232
+ return {
233
+ kind: "user",
234
+ id: info.data.user_id.toString(),
235
+ name: info.data.nickname,
236
+ };
237
+ },
238
+ listPeers: getFriends,
239
+ listPeersLive: getFriends,
240
+ listGroups: getGroups,
241
+ listGroupsLive: getGroups,
213
242
  }
214
243
  };
215
244
  async function outboundSend(ctx) {
@@ -265,3 +294,21 @@ async function outboundSend(ctx) {
265
294
  };
266
295
  }
267
296
  }
297
+ async function getFriends() {
298
+ const friendList = await getFriendList();
299
+ log.debug('directory', `friendList: ${JSON.stringify(friendList.data)}`);
300
+ return (friendList.data || []).map((friend) => ({
301
+ kind: "user",
302
+ id: friend.user_id.toString(),
303
+ name: friend.nickname,
304
+ }));
305
+ }
306
+ async function getGroups() {
307
+ const groupList = await getGroupList();
308
+ log.debug('directory', `groupList: ${JSON.stringify(groupList.data)}`);
309
+ return (groupList.data || []).map((group) => ({
310
+ kind: "group",
311
+ id: group.group_id.toString(),
312
+ name: group.group_name,
313
+ }));
314
+ }
@@ -19,4 +19,6 @@ export declare const QQConfigSchema: z.ZodObject<{
19
19
  wsUrl: z.ZodDefault<z.ZodString>;
20
20
  accessToken: z.ZodDefault<z.ZodString>;
21
21
  enable: z.ZodDefault<z.ZodBoolean>;
22
+ groupAtMode: z.ZodDefault<z.ZodBoolean>;
23
+ groupHistoryLimit: z.ZodDefault<z.ZodNumber>;
22
24
  }, z.core.$strip>;
@@ -23,6 +23,8 @@ export function resolveQQAccount(params) {
23
23
  enabled: config?.enabled !== false,
24
24
  wsUrl: config?.wsUrl ?? "",
25
25
  accessToken: config?.accessToken,
26
+ groupAtMode: config?.groupAtMode ?? true,
27
+ groupHistoryLimit: config?.groupHistoryLimit ?? 20,
26
28
  };
27
29
  }
28
30
  /**
@@ -31,9 +33,12 @@ export function resolveQQAccount(params) {
31
33
  const wsUrlRegex = /^wss?:\/\/[\w.-]+(:\d+)?(\/[\w./-]*)?$/;
32
34
  const wsUrlSchema = z.string()
33
35
  .regex(wsUrlRegex, { message: "Invalid WebSocket URL format. Expected: ws://host:port or wss://host:port" })
34
- .default("ws://127.0.0.1:3001");
36
+ .default("ws://127.0.0.1:3001")
37
+ .describe("NapCat Websocket 连接地址");
35
38
  export const QQConfigSchema = z.object({
36
39
  wsUrl: wsUrlSchema,
37
- accessToken: z.string().default("access-token"),
38
- enable: z.boolean().default(true)
40
+ accessToken: z.string().default("access-token").describe("NapCat Websocket Token"),
41
+ enable: z.boolean().default(true).describe("是否启用"),
42
+ groupAtMode: z.boolean().default(true).describe("群组响应模式:默认启用,只有在被@时才会响应"),
43
+ groupHistoryLimit: z.number().default(20).describe("群组历史记录信息条数限制"),
39
44
  });
@@ -2,7 +2,8 @@
2
2
  * Message Dispatch Module
3
3
  * Handles routing and dispatching incoming messages to the AI
4
4
  */
5
- import { getRuntime, getContext } from './runtime.js';
5
+ import { buildPendingHistoryContextFromMap, clearHistoryEntries, recordPendingHistoryEntry, resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk";
6
+ import { getRuntime, getContext, getSession, clearSession, updateSession, getLoginInfo, historyCache } from './runtime.js';
6
7
  import { getFile, sendMsg, setInputStatus } from './request.js';
7
8
  import { napCatToOpenClawMessage, openClawToNapCatMessage } from '../adapters/message.js';
8
9
  import { Logger as log, markdownToText, buildMediaMessage } from '../utils/index.js';
@@ -13,26 +14,38 @@ import { CHANNEL_ID } from "./config.js";
13
14
  * For replies, includes quoted message content if available
14
15
  */
15
16
  async function contentToPlainText(content) {
16
- return content
17
- .filter(c => c.type !== 'image' && c.type !== 'audio' && c.type !== 'file')
18
- .map((c) => {
17
+ const results = await Promise.all(content.map(async (c) => {
19
18
  switch (c.type) {
20
19
  case 'text':
21
- return `${c.text}`;
20
+ return c.text;
22
21
  case 'at':
23
- return c.isAll ? '@全体成员' : `@${c.userId}`;
22
+ const target = c.isAll ? '@全体成员' : `@${c.userId}`;
23
+ return `[AT]${target}`;
24
+ case 'image':
25
+ return `[图片]${c.url}`;
26
+ case 'audio':
27
+ return `[音频]${c.path}`;
28
+ case 'video':
29
+ return `[视频]${c.url}`;
30
+ case 'file': {
31
+ const fileInfo = await getFile({ file_id: c.fileId });
32
+ if (!fileInfo.data?.file)
33
+ return null;
34
+ return `[文件]${fileInfo.data.file}`;
35
+ }
24
36
  case 'json':
25
- return `[JSON]\n\`\`\`json\n${c.data}\n\`\`\``;
26
- case 'reply':
27
- const senderInfo = c.sender && c.senderId ? `${c.sender}(${c.senderId})` : '未知用户';
28
- const replyMsg = c.message ?? '[无法获取原消息]';
29
- let replyContent = `${senderInfo}:\n${replyMsg}`;
30
- replyContent = replyContent.split('\n').map(line => `> ${line}`).join('\n');
31
- return `[回复]\n${replyContent}\n`;
37
+ return `[JSON]\n\n\`\`\`json\n${c.data}\n\`\`\``;
38
+ case 'reply': {
39
+ const senderInfo = c.sender && c.senderId ? `${c.sender}(${c.senderId})` : '(未知用户)';
40
+ const replyMsg = c.message ?? '(无法获取原消息)';
41
+ const quotedContent = `${senderInfo}:\n${replyMsg}`.replace(/^/gm, '> ');
42
+ return `[回复]\n\n${quotedContent}`;
43
+ }
32
44
  default:
33
- return '';
45
+ return null;
34
46
  }
35
- }).join('\n');
47
+ }));
48
+ return results.filter((v) => v !== null).join('\n');
36
49
  }
37
50
  async function contextToMedia(content) {
38
51
  const hasMedia = content.some(c => c.type === 'image' || c.type === 'audio' || c.type === 'file');
@@ -104,7 +117,7 @@ async function sendMedia(isGroup, chatId, mediaUrl) {
104
117
  * Dispatch an incoming message to the AI for processing
105
118
  */
106
119
  export async function dispatchMessage(params) {
107
- const { chatType, chatId, senderId, senderName, messageId, content, media, timestamp } = params;
120
+ let { chatType, chatId, senderId, senderName, messageId, content, media, timestamp, targetId } = params;
108
121
  const runtime = getRuntime();
109
122
  if (!runtime) {
110
123
  log.warn('dispatch', `Plugin runtime not available`);
@@ -116,31 +129,65 @@ export async function dispatchMessage(params) {
116
129
  return;
117
130
  }
118
131
  const isGroup = chatType === 'group';
119
- const peerId = isGroup ? `group:${chatId}` : senderId;
120
- const route = runtime.channel.routing.resolveAgentRoute({
132
+ const config = context.account;
133
+ // At 模式处理
134
+ if (isGroup && config.groupAtMode) {
135
+ const loginInfo = getLoginInfo();
136
+ const hasAtAll = content.includes('[AT]@全体成员');
137
+ const hasAtMe = loginInfo.userId && content.includes(`[AT]@${loginInfo.userId}`);
138
+ const hasPoke = content.includes('[动作]') && targetId === loginInfo.userId;
139
+ if (!hasAtAll && !hasAtMe && !hasPoke) {
140
+ log.debug('dispatch', `Skipping group message (not mentioned)`);
141
+ recordPendingHistoryEntry({
142
+ historyMap: historyCache,
143
+ historyKey: chatId,
144
+ limit: config.groupHistoryLimit,
145
+ entry: {
146
+ sender: `${senderName}(${senderId})`,
147
+ body: content,
148
+ timestamp: timestamp,
149
+ messageId: messageId,
150
+ },
151
+ });
152
+ return;
153
+ }
154
+ }
155
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
121
156
  cfg: context.cfg,
122
157
  channel: CHANNEL_ID,
158
+ accountId: context.accountId,
123
159
  peer: {
124
- kind: 'group',
125
- id: peerId,
160
+ kind: isGroup ? "group" : "direct",
161
+ id: chatId,
126
162
  },
163
+ runtime: runtime.channel,
164
+ sessionStore: context.cfg.session?.store
127
165
  });
128
- log.debug('dispatch', `Resolved route: ${JSON.stringify(route)}`);
129
- const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(context.cfg);
130
- const body = runtime.channel.reply.formatInboundEnvelope({
166
+ // 终止信号
167
+ const session = getSession(route.sessionKey);
168
+ if (session.abortController) {
169
+ session.abortController.abort();
170
+ session.aborted = true;
171
+ log.info('dispatch', `Aborted previous session`);
172
+ }
173
+ if (isGroup) {
174
+ content = buildPendingHistoryContextFromMap({
175
+ historyMap: historyCache,
176
+ historyKey: chatId,
177
+ limit: config.groupHistoryLimit,
178
+ currentMessage: content,
179
+ formatEntry: (e) => `${e.sender}: ${e.body}`,
180
+ });
181
+ }
182
+ const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
183
+ const { storePath, body } = buildEnvelope({
131
184
  channel: CHANNEL_ID,
132
- from: senderName || senderId,
185
+ from: fromLabel,
133
186
  body: content,
134
187
  timestamp,
135
- chatType: isGroup ? 'group' : 'direct',
136
- sender: {
137
- id: senderId,
138
- name: senderName,
139
- },
140
- envelope: envelopeOptions,
141
188
  });
142
189
  log.debug('dispatch', `Inbound envelope: ${body}`);
143
- const fromAddress = isGroup ? `qq:group:${chatId}` : `qq:${senderId}`;
190
+ const fromAddress = `qq:${fromLabel}`;
144
191
  const toAddress = `qq:${chatId}`;
145
192
  const ctxPayload = runtime.channel.reply.finalizeInboundContext({
146
193
  Body: body,
@@ -151,6 +198,7 @@ export async function dispatchMessage(params) {
151
198
  SessionKey: route.sessionKey,
152
199
  AccountId: route.accountId,
153
200
  ChatType: isGroup ? 'group' : 'direct',
201
+ ConversationLabel: fromLabel,
154
202
  SenderId: senderId,
155
203
  SenderName: senderName,
156
204
  Provider: CHANNEL_ID,
@@ -164,8 +212,18 @@ export async function dispatchMessage(params) {
164
212
  OriginatingTo: toAddress,
165
213
  });
166
214
  log.info('dispatch', `Dispatching to agent ${route.agentId}, session: ${route.sessionKey}`);
215
+ await runtime.channel.session.recordInboundSession({
216
+ storePath,
217
+ sessionKey: route.sessionKey,
218
+ ctx: ctxPayload,
219
+ onRecordError(err) {
220
+ log.error('dispatch', `Failed to record inbound session: ${err}`);
221
+ },
222
+ });
167
223
  const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(context.cfg, route.agentId);
168
224
  try {
225
+ session.abortController = new AbortController();
226
+ updateSession(route.sessionKey, session);
169
227
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
170
228
  ctx: ctxPayload,
171
229
  cfg: context.cfg,
@@ -184,6 +242,14 @@ export async function dispatchMessage(params) {
184
242
  }
185
243
  },
186
244
  deliver: async (payload, info) => {
245
+ if (session.aborted) {
246
+ session.aborted = false;
247
+ log.info('dispatch', `aborted skipping`);
248
+ return;
249
+ }
250
+ if (isGroup) {
251
+ clearHistoryEntries({ historyMap: historyCache, historyKey: chatId });
252
+ }
187
253
  log.info('dispatch', `deliver(${info.kind}): ${JSON.stringify(payload)}`);
188
254
  if (payload.text && !payload.text.startsWith('MEDIA:')) {
189
255
  await sendText(isGroup, chatId, payload.text);
@@ -205,7 +271,9 @@ export async function dispatchMessage(params) {
205
271
  await sendText(isGroup, chatId, `[错误]\n${String(err)}`);
206
272
  },
207
273
  },
208
- replyOptions: {},
274
+ replyOptions: {
275
+ abortSignal: session.abortController?.signal,
276
+ },
209
277
  });
210
278
  log.info('dispatch', `Dispatch completed`);
211
279
  }
@@ -220,6 +288,7 @@ export async function dispatchMessage(params) {
220
288
  event_type: 2
221
289
  });
222
290
  }
291
+ clearSession(route.sessionKey);
223
292
  }
224
293
  }
225
294
  /**
@@ -281,7 +350,8 @@ export async function handlePokeEvent(event) {
281
350
  senderId: String(event.user_id),
282
351
  senderName: String(event.user_id),
283
352
  messageId: `poke_${event.user_id}_${Date.now()}`,
284
- content: `[动作]\n${pokeMessage}`,
353
+ content: `[动作]${pokeMessage}`,
285
354
  timestamp: Date.now(),
355
+ targetId: String(event.target_id),
286
356
  });
287
357
  }
@@ -1,4 +1,4 @@
1
- import type { GetFileReq, GetFileResp, GetMsgReq, GetMsgResp, GetStatusResp, NapCatResp, SendMsgReq, SendMsgResp, SetInputStatusReq } from "../types";
1
+ import type { GetFileReq, GetFileResp, GetMsgReq, GetMsgResp, GetStatusResp, GetLoginInfoResp, NapCatResp, SendMsgReq, SendMsgResp, SetInputStatusReq, GetFriendListResp, GetGroupListResp } from "../types";
2
2
  /**
3
3
  * 事件监听
4
4
  * @param event
@@ -28,3 +28,15 @@ export declare function setInputStatus(params: SetInputStatusReq): Promise<NapCa
28
28
  * 获取状态
29
29
  */
30
30
  export declare function getStatus(): Promise<NapCatResp<GetStatusResp>>;
31
+ /**
32
+ * 获取登录信息
33
+ */
34
+ export declare function getLoginInfo(): Promise<NapCatResp<GetLoginInfoResp>>;
35
+ /**
36
+ * 获取好友列表
37
+ */
38
+ export declare function getFriendList(): Promise<NapCatResp<GetFriendListResp[]>>;
39
+ /**
40
+ * 获取群列表
41
+ */
42
+ export declare function getGroupList(): Promise<NapCatResp<GetGroupListResp[]>>;
@@ -136,3 +136,36 @@ export async function getStatus() {
136
136
  }
137
137
  return connection.sendRequest("get_status");
138
138
  }
139
+ /**
140
+ * 获取登录信息
141
+ */
142
+ export async function getLoginInfo() {
143
+ const connection = getConnection();
144
+ if (!connection) {
145
+ log.warn("request", `No connection available`);
146
+ return failResp();
147
+ }
148
+ return connection.sendRequest("get_login_info");
149
+ }
150
+ /**
151
+ * 获取好友列表
152
+ */
153
+ export async function getFriendList() {
154
+ const connection = getConnection();
155
+ if (!connection) {
156
+ log.warn("request", `No connection available`);
157
+ return failResp();
158
+ }
159
+ return connection.sendRequest("get_friend_list");
160
+ }
161
+ /**
162
+ * 获取群列表
163
+ */
164
+ export async function getGroupList() {
165
+ const connection = getConnection();
166
+ if (!connection) {
167
+ log.warn("request", `No connection available`);
168
+ return failResp();
169
+ }
170
+ return connection.sendRequest("get_group_list");
171
+ }
@@ -2,8 +2,8 @@
2
2
  * Plugin Runtime Storage
3
3
  * Stores the PluginRuntime for access in gateway handlers
4
4
  */
5
- import type { ChannelAccountSnapshot, ChannelGatewayContext, PluginRuntime } from "openclaw/plugin-sdk";
6
- import type { QQConfig } from "../types";
5
+ import type { ChannelAccountSnapshot, ChannelGatewayContext, HistoryEntry, PluginRuntime } from "openclaw/plugin-sdk";
6
+ import { QQConfig, QQLoginInfo, QQSession } 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;
@@ -14,3 +14,9 @@ export declare function setContextStatus(next: Omit<ChannelAccountSnapshot, 'acc
14
14
  export declare function setConnection(next: ConnectionManager): void;
15
15
  export declare function getConnection(): ConnectionManager | null;
16
16
  export declare function clearConnection(): void;
17
+ export declare function getSession(sessionKey: string): QQSession;
18
+ export declare function updateSession(sessionKey: string, session: QQSession): void;
19
+ export declare function clearSession(sessionKey: string): void;
20
+ export declare function setLoginInfo(next: QQLoginInfo): void;
21
+ export declare function getLoginInfo(): QQLoginInfo;
22
+ export declare const historyCache: Map<string, HistoryEntry[]>;
@@ -46,3 +46,39 @@ export function getConnection() {
46
46
  export function clearConnection() {
47
47
  connection = null;
48
48
  }
49
+ // =============================================================================
50
+ // Session
51
+ // =============================================================================
52
+ const sessionMap = new Map();
53
+ export function getSession(sessionKey) {
54
+ let session = sessionMap.get(sessionKey);
55
+ if (session) {
56
+ return session;
57
+ }
58
+ session = {};
59
+ sessionMap.set(sessionKey, session);
60
+ return session;
61
+ }
62
+ export function updateSession(sessionKey, session) {
63
+ sessionMap.set(sessionKey, session);
64
+ }
65
+ export function clearSession(sessionKey) {
66
+ sessionMap.delete(sessionKey);
67
+ }
68
+ // =============================================================================
69
+ // LoginInfo
70
+ // =============================================================================
71
+ const loginInfo = {
72
+ userId: '',
73
+ nickname: '',
74
+ };
75
+ export function setLoginInfo(next) {
76
+ Object.assign(loginInfo, next);
77
+ }
78
+ export function getLoginInfo() {
79
+ return loginInfo;
80
+ }
81
+ // =============================================================================
82
+ // History
83
+ // =============================================================================
84
+ export const historyCache = new Map();
@@ -14,7 +14,7 @@ export interface NapCatResp<T = unknown> {
14
14
  data?: T;
15
15
  echo?: string;
16
16
  }
17
- export type NapCatAction = 'send_msg' | 'get_msg' | 'get_status' | 'get_file' | 'set_input_status';
17
+ export type NapCatAction = 'send_msg' | 'get_msg' | 'get_status' | 'get_file' | 'get_login_info' | 'get_friend_list' | 'get_group_list' | 'set_input_status';
18
18
  export interface NapCatEvent {
19
19
  time: number;
20
20
  self_id: number;
@@ -26,7 +26,7 @@ export interface NapCatMetaEvent extends NapCatEvent {
26
26
  meta_event_type: 'lifecycle' | 'heartbeat';
27
27
  sub_type?: 'connect' | 'disconnect' | 'enable' | 'disable';
28
28
  }
29
- export type NapCatMessage = NapCatTextSegment | NapCatAtSegment | NapCatImageSegment | NapCatReplySegment | NapCatFaceSegment | NapCatRecordSegment | NapCatFileSegment | NapCatJsonSegment | NapCatUnknownSegment;
29
+ export type NapCatMessage = NapCatTextSegment | NapCatAtSegment | NapCatImageSegment | NapCatReplySegment | NapCatFaceSegment | NapCatRecordSegment | NapCatFileSegment | NapCatJsonSegment | NapCatUnknownSegment | NapCatVideoSegment;
30
30
  export interface NapCatTextSegment {
31
31
  type: 'text';
32
32
  data: {
@@ -85,15 +85,17 @@ export interface NapCatJsonSegment {
85
85
  data: string;
86
86
  };
87
87
  }
88
+ export interface NapCatVideoSegment {
89
+ type: 'video';
90
+ data: {
91
+ url: string;
92
+ file_size?: string;
93
+ };
94
+ }
88
95
  export interface NapCatUnknownSegment {
89
96
  type: string;
90
97
  data: Record<string, unknown>;
91
98
  }
92
- export interface QQConfig {
93
- wsUrl: string;
94
- accessToken?: string;
95
- enabled: boolean;
96
- }
97
99
  export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'failed';
98
100
  export interface ConnectionStatus {
99
101
  state: ConnectionState;
@@ -102,6 +104,7 @@ export interface ConnectionStatus {
102
104
  error?: string;
103
105
  reconnectAttempts?: number;
104
106
  }
107
+ export type OpenClawMessage = OpenClawTextContent | OpenClawAtContent | OpenClawImageContent | OpenClawReplyContent | OpenClawAudioContent | OpenClawJsonContent | OpenClawFileContent | OpenClawVideoContent;
105
108
  export interface OpenClawTextContent {
106
109
  type: 'text';
107
110
  text: string;
@@ -114,7 +117,6 @@ export interface OpenClawAtContent {
114
117
  export interface OpenClawImageContent {
115
118
  type: 'image';
116
119
  url: string;
117
- /** Optional summary/description (e.g., "[动画表情]" for animated stickers) */
118
120
  summary?: string;
119
121
  }
120
122
  export interface OpenClawReplyContent {
@@ -126,20 +128,14 @@ export interface OpenClawReplyContent {
126
128
  }
127
129
  export interface OpenClawAudioContent {
128
130
  type: 'audio';
129
- /** Local file path to the audio file */
130
131
  path: string;
131
- /** Optional URL for downloading the audio */
132
132
  url?: string;
133
- /** File name */
134
133
  file: string;
135
- /** File size in bytes */
136
134
  fileSize?: number;
137
135
  }
138
136
  export interface OpenClawJsonContent {
139
137
  type: 'json';
140
- /** Raw JSON data string */
141
138
  data: string;
142
- /** Optional display text/prompt from the JSON */
143
139
  prompt?: string;
144
140
  }
145
141
  export interface OpenClawFileContent {
@@ -149,7 +145,11 @@ export interface OpenClawFileContent {
149
145
  url?: string;
150
146
  fileSize?: number;
151
147
  }
152
- export type OpenClawMessage = OpenClawTextContent | OpenClawAtContent | OpenClawImageContent | OpenClawReplyContent | OpenClawAudioContent | OpenClawJsonContent | OpenClawFileContent;
148
+ export interface OpenClawVideoContent {
149
+ type: 'video';
150
+ url?: string;
151
+ fileSize?: number;
152
+ }
153
153
  export interface PendingRequest {
154
154
  resolve: (response: NapCatResp) => void;
155
155
  reject: (error: Error) => void;
@@ -248,6 +248,18 @@ export interface GetStatusResp {
248
248
  good: boolean;
249
249
  stat: Record<any, any>;
250
250
  }
251
+ export interface GetLoginInfoResp {
252
+ user_id: number;
253
+ nickname: string;
254
+ }
255
+ export interface GetFriendListResp {
256
+ user_id: number;
257
+ nickname: string;
258
+ }
259
+ export interface GetGroupListResp {
260
+ group_id: number;
261
+ group_name: string;
262
+ }
251
263
  export interface DispatchMessageMedia {
252
264
  type?: string;
253
265
  path?: string;
@@ -262,9 +274,25 @@ export interface DispatchMessageParams {
262
274
  content: string;
263
275
  media?: DispatchMessageMedia;
264
276
  timestamp: number;
277
+ targetId?: string;
278
+ }
279
+ export interface QQConfig {
280
+ wsUrl: string;
281
+ accessToken?: string;
282
+ enabled: boolean;
283
+ groupAtMode: boolean;
284
+ groupHistoryLimit: number;
265
285
  }
266
286
  export type QQProbe = {
267
287
  ok: boolean;
268
288
  status?: number | null;
269
289
  error?: string | null;
270
290
  };
291
+ export type QQSession = {
292
+ abortController?: AbortController;
293
+ aborted?: boolean;
294
+ };
295
+ export type QQLoginInfo = {
296
+ userId: string;
297
+ nickname: string;
298
+ };
@@ -48,9 +48,8 @@ export class MarkdownToText {
48
48
  // 逻辑:匹配 < 后紧跟字母的模式,保留 "a < b" 或 "1 < 5" 这种数学公式
49
49
  text = text.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, '');
50
50
  // 3.3 标题 (Headers) -> 视觉醒目文本
51
- text = text.replace(/^#\s+(.*)$/gm, '\n$1\n══════════\n');
52
- text = text.replace(/^##\s+(.*)$/gm, '\n$1\n──────────\n');
53
- text = text.replace(/^(#{3,6})\s+(.*)$/gm, '\n【 $2 】\n');
51
+ text = text.replace(/^#\s+(.*)$/gm, '\n$1\n\n\n');
52
+ text = text.replace(/^(#{2,6})\s+(.*)$/gm, '\n$2\n\n');
54
53
  // 3.4 Markdown 分割线 (---, ***)
55
54
  text = text.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, '──────────');
56
55
  // 3.5 引用 (Blockquotes)
@@ -94,7 +93,7 @@ export class MarkdownToText {
94
93
  return text.replace(codeBlockRegex, (_match, _fence, lang, code) => {
95
94
  const key = `${this.maskPrefix}BLOCK-${this.maskCounter++}`;
96
95
  const langTag = lang ? ` [${lang}]` : '';
97
- const formatted = `\n───code───${langTag}\n${code.replace(/^\n+|\n+$/g, '')}\n──────────\n`;
96
+ const formatted = `\n──────────${langTag}\n${code.replace(/^\n+|\n+$/g, '')}\n──────────\n`;
98
97
  this.codeBlockStore.set(key, formatted);
99
98
  return key;
100
99
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izhimu/qq",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "A QQ channel plugin for OpenClaw using NapCat WebSocket",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,7 +41,7 @@
41
41
  }
42
42
  },
43
43
  "peerDependencies": {
44
- "openclaw": "^2026.2.1"
44
+ "openclaw": "^2026.3.1"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {
@@ -56,7 +56,7 @@
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.0.0",
58
58
  "@types/ws": "^8.18.0",
59
- "openclaw": "^2026.2.1",
59
+ "openclaw": "^2026.3.1",
60
60
  "typescript": "^5.0.0"
61
61
  }
62
62
  }