@izhimu/qq 0.3.2 → 0.5.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
  ### 本地开发安装
@@ -141,6 +144,10 @@ openclaw gateway restart
141
144
  | `wsUrl` | `string` | 是 | - | NapCat WebSocket 地址 |
142
145
  | `accessToken` | `string` | 否 | `""` | 访问令牌(如配置了认证) |
143
146
  | `enabled` | `boolean` | 否 | `true` | 是否启用该账号 |
147
+ | `markdownFormat` | `boolean` | 否 | `true` | 是否启用 Markdown 格式化转换 |
148
+ | `messageDirect` | `object` | 否 | - | 私聊全局配置(策略、黑白名单) |
149
+ | `messageGroup` | `object` | 否 | - | 群组全局配置(@响应、戳一戳、唤醒词等) |
150
+ | `messageGroupsCustom` | `object` | 否 | `{}` | 特定群组的独立配置 |
144
151
 
145
152
  ### 配置示例
146
153
 
@@ -150,7 +157,19 @@ openclaw gateway restart
150
157
  "qq": {
151
158
  "wsUrl": "ws://127.0.0.1:3001",
152
159
  "accessToken": "your-token",
153
- "enabled": true
160
+ "enabled": true,
161
+ "markdownFormat": true,
162
+ "messageDirect": {
163
+ "policy": "allow",
164
+ "denyFrom": ["12345678"]
165
+ },
166
+ "messageGroup": {
167
+ "requireMention": true,
168
+ "requirePoke": true,
169
+ "policy": "allowlist",
170
+ "allowFrom": ["123456"],
171
+ "wakeWord": "小艺"
172
+ }
154
173
  }
155
174
  }
156
175
  }
@@ -259,6 +278,7 @@ openclaw-channel-qq/
259
278
  | `reply` | ✓ | ✓ | 消息回复 |
260
279
  | `record` | ✓ | ✓ | 语音消息 |
261
280
  | `file` | ✓ | ✓ | 文件 |
281
+ | `video` | ✓ | - | 视频消息 |
262
282
  | `json` | ✓ | - | JSON 富文本 |
263
283
 
264
284
  ### OneBot 11 接口
@@ -371,6 +391,35 @@ npm run build
371
391
 
372
392
  ## 更新日志
373
393
 
394
+ ### [0.5.0] - 2026-03-11
395
+
396
+ #### 新增
397
+ - **多媒体支持增强**:新增对视频(`video`)消息类型的入站支持。
398
+ - **细粒度访问控制**:
399
+ - 新增 `messageDirect` 和 `messageGroup` 配置项,支持私聊和群组的独立策略控制(`allow`, `deny`, `allowlist`)。
400
+ - 支持基于用户 ID 的黑白名单过滤。
401
+ - **群组触发增强**:
402
+ - 支持自定义群组唤醒词(`wakeWord`)。
403
+ - 支持开启/关闭戳一戳(`requirePoke`)响应。
404
+ - 支持针对特定群组进行独立配置(`messageGroupsCustom`)。
405
+ - **Markdown 优化**:新增 `markdownFormat` 开关,可选择是否将 Markdown 转换为纯文本。
406
+
407
+ #### 优化
408
+ - **配置结构重构**:重构了配置解析逻辑,提高了配置项的灵活性。
409
+ - **消息文本转换**:优化了多媒体消息(图片、视频、音频、文件等)在日志和历史记录中的纯文本展示效果。
410
+ - **稳定性**:增加了 Gateway Context 的预检,提升了系统的健壮性。
411
+
412
+ ### [0.4.0] - 2026-03-07
413
+
414
+ #### 新增
415
+ - 群 At 模式(`groupAtMode`)- 开启后只有被 @ 或 @全体成员 时才回复
416
+ - 登录信息存储功能,获取并保存当前登录 QQ 号
417
+ - 消息中断处理机制,新消息到来时正确终止上一条消息的回复
418
+
419
+ #### 修复
420
+ - 修复 abort 后 deliver 仍然发送已终止消息的问题
421
+ - 修复 abortController 状态检查不准确的问题(使用独立 aborted 标志)
422
+
374
423
  ### [0.3.0] - 2026-02-12
375
424
 
376
425
  #### 新增
@@ -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, getContext } from "./core/runtime.js";
8
8
  import { ConnectionManager } from "./core/connection.js";
9
9
  import { openClawToNapCatMessage } from "./adapters/message.js";
10
10
  import { listQQAccountIds, resolveQQAccount, QQConfigSchema, CHANNEL_ID } from "./core/config.js";
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) {
@@ -221,8 +250,18 @@ async function outboundSend(ctx) {
221
250
  const chatType = type === "group" ? "group" : "private";
222
251
  const chatId = id || to;
223
252
  const content = [];
253
+ const context = getContext();
254
+ if (!context) {
255
+ log.warn('dispatch', `No gateway context`);
256
+ return {
257
+ channel: CHANNEL_ID,
258
+ messageId: "",
259
+ error: new Error(`No gateway context`),
260
+ deliveredAt: Date.now(),
261
+ };
262
+ }
224
263
  if (text) {
225
- content.push({ type: "text", text: markdownToText(text) });
264
+ content.push({ type: "text", text: context.account.markdownFormat ? markdownToText(text) : text });
226
265
  }
227
266
  if (mediaUrl) {
228
267
  content.push(buildMediaMessage(mediaUrl));
@@ -265,3 +304,21 @@ async function outboundSend(ctx) {
265
304
  };
266
305
  }
267
306
  }
307
+ async function getFriends() {
308
+ const friendList = await getFriendList();
309
+ log.debug('directory', `friendList: ${JSON.stringify(friendList.data)}`);
310
+ return (friendList.data || []).map((friend) => ({
311
+ kind: "user",
312
+ id: friend.user_id.toString(),
313
+ name: friend.nickname,
314
+ }));
315
+ }
316
+ async function getGroups() {
317
+ const groupList = await getGroupList();
318
+ log.debug('directory', `groupList: ${JSON.stringify(groupList.data)}`);
319
+ return (groupList.data || []).map((group) => ({
320
+ kind: "group",
321
+ id: group.group_id.toString(),
322
+ name: group.group_name,
323
+ }));
324
+ }
@@ -15,8 +15,66 @@ export declare function listQQAccountIds(cfg: OpenClawConfig): string[];
15
15
  export declare function resolveQQAccount(params: {
16
16
  cfg: OpenClawConfig;
17
17
  }): QQConfig;
18
+ export declare const QQDirectConfigSchema: z.ZodObject<{
19
+ policy: z.ZodDefault<z.ZodEnum<{
20
+ allow: "allow";
21
+ deny: "deny";
22
+ allowlist: "allowlist";
23
+ }>>;
24
+ allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
25
+ denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
26
+ }, z.core.$strip>;
27
+ export declare const QQGroupConfigSchema: z.ZodObject<{
28
+ requireMention: z.ZodDefault<z.ZodBoolean>;
29
+ requirePoke: z.ZodDefault<z.ZodBoolean>;
30
+ historyLimit: z.ZodDefault<z.ZodNumber>;
31
+ policy: z.ZodDefault<z.ZodEnum<{
32
+ allow: "allow";
33
+ deny: "deny";
34
+ allowlist: "allowlist";
35
+ }>>;
36
+ allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
37
+ denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
38
+ wakeWord: z.ZodOptional<z.ZodString>;
39
+ }, z.core.$strip>;
18
40
  export declare const QQConfigSchema: z.ZodObject<{
19
41
  wsUrl: z.ZodDefault<z.ZodString>;
20
42
  accessToken: z.ZodDefault<z.ZodString>;
21
43
  enable: z.ZodDefault<z.ZodBoolean>;
44
+ markdownFormat: z.ZodDefault<z.ZodBoolean>;
45
+ messageDirect: z.ZodObject<{
46
+ policy: z.ZodDefault<z.ZodEnum<{
47
+ allow: "allow";
48
+ deny: "deny";
49
+ allowlist: "allowlist";
50
+ }>>;
51
+ allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
52
+ denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
53
+ }, z.core.$strip>;
54
+ messageGroup: z.ZodObject<{
55
+ requireMention: z.ZodDefault<z.ZodBoolean>;
56
+ requirePoke: z.ZodDefault<z.ZodBoolean>;
57
+ historyLimit: z.ZodDefault<z.ZodNumber>;
58
+ policy: z.ZodDefault<z.ZodEnum<{
59
+ allow: "allow";
60
+ deny: "deny";
61
+ allowlist: "allowlist";
62
+ }>>;
63
+ allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
64
+ denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
65
+ wakeWord: z.ZodOptional<z.ZodString>;
66
+ }, z.core.$strip>;
67
+ messageGroupsCustom: z.ZodOptional<z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
68
+ requireMention: z.ZodDefault<z.ZodBoolean>;
69
+ requirePoke: z.ZodDefault<z.ZodBoolean>;
70
+ historyLimit: z.ZodDefault<z.ZodNumber>;
71
+ policy: z.ZodDefault<z.ZodEnum<{
72
+ allow: "allow";
73
+ deny: "deny";
74
+ allowlist: "allowlist";
75
+ }>>;
76
+ allowFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
77
+ denyFrom: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
78
+ wakeWord: z.ZodOptional<z.ZodString>;
79
+ }, z.core.$strip>>>>;
22
80
  }, z.core.$strip>;
@@ -23,6 +23,22 @@ export function resolveQQAccount(params) {
23
23
  enabled: config?.enabled !== false,
24
24
  wsUrl: config?.wsUrl ?? "",
25
25
  accessToken: config?.accessToken,
26
+ markdownFormat: config?.markdownFormat ?? true,
27
+ messageDirect: {
28
+ policy: config?.messageDirect?.policy ?? "allow",
29
+ allowFrom: config?.messageDirect?.allowFrom ?? [],
30
+ denyFrom: config?.messageDirect?.denyFrom ?? [],
31
+ },
32
+ messageGroup: {
33
+ requireMention: config?.messageGroup?.requireMention ?? true,
34
+ requirePoke: config?.messageGroup?.requirePoke ?? true,
35
+ policy: config?.messageGroup?.policy ?? "allow",
36
+ historyLimit: config?.messageGroup?.historyLimit ?? 20,
37
+ allowFrom: config?.messageGroup?.allowFrom ?? [],
38
+ denyFrom: config?.messageGroup?.denyFrom ?? [],
39
+ wakeWord: config?.messageGroup?.wakeWord ?? undefined,
40
+ },
41
+ messageGroupsCustom: config?.messageGroupsCustom ?? {},
26
42
  };
27
43
  }
28
44
  /**
@@ -31,9 +47,28 @@ export function resolveQQAccount(params) {
31
47
  const wsUrlRegex = /^wss?:\/\/[\w.-]+(:\d+)?(\/[\w./-]*)?$/;
32
48
  const wsUrlSchema = z.string()
33
49
  .regex(wsUrlRegex, { message: "Invalid WebSocket URL format. Expected: ws://host:port or wss://host:port" })
34
- .default("ws://127.0.0.1:3001");
50
+ .default("ws://127.0.0.1:3001")
51
+ .describe("NapCat Websocket 连接地址");
52
+ export const QQDirectConfigSchema = z.object({
53
+ policy: z.enum(["allow", "deny", "allowlist"]).default("allow").describe("私聊策略"),
54
+ allowFrom: z.array(z.string()).default([]).describe("允许的用户").optional(),
55
+ denyFrom: z.array(z.string()).default([]).describe("拒绝的用户").optional(),
56
+ }).describe("私聊全局配置");
57
+ export const QQGroupConfigSchema = z.object({
58
+ requireMention: z.boolean().default(true).describe("群组是否需要@响应"),
59
+ requirePoke: z.boolean().default(true).describe("群组支持戳一戳响应"),
60
+ historyLimit: z.number().default(20).describe("群组历史记录信息条数"),
61
+ policy: z.enum(["allow", "deny", "allowlist"]).default("allow").describe("群组策略"),
62
+ allowFrom: z.array(z.string()).default([]).describe("群组允许的用户").optional(),
63
+ denyFrom: z.array(z.string()).default([]).describe("群组拒绝的用户").optional(),
64
+ wakeWord: z.string().describe("群组唤醒词").optional(),
65
+ }).describe("群组全局配置");
35
66
  export const QQConfigSchema = z.object({
36
67
  wsUrl: wsUrlSchema,
37
- accessToken: z.string().default("access-token"),
38
- enable: z.boolean().default(true)
68
+ accessToken: z.string().default("access-token").describe("NapCat Websocket Token"),
69
+ enable: z.boolean().default(true).describe("是否启用"),
70
+ markdownFormat: z.boolean().default(true).describe("是否启动 Markdown 格式化转换"),
71
+ messageDirect: QQDirectConfigSchema,
72
+ messageGroup: QQGroupConfigSchema,
73
+ messageGroupsCustom: z.record(z.string(), QQGroupConfigSchema).default({}).describe("特定群组配置").optional(),
39
74
  });
@@ -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 `[提及]${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');
@@ -70,8 +83,16 @@ async function contextToMedia(content) {
70
83
  return;
71
84
  }
72
85
  async function sendText(isGroup, chatId, text) {
73
- const cleanText = text.replace(/NO_REPLY\s*$/, '');
74
- const messageSegments = [{ type: 'text', data: { text: markdownToText(cleanText) } }];
86
+ const contextText = text.replace(/NO_REPLY\s*$/, '');
87
+ const context = getContext();
88
+ if (!context) {
89
+ log.warn('dispatch', `No gateway context`);
90
+ return;
91
+ }
92
+ const messageSegments = [{
93
+ type: 'text',
94
+ data: { text: context.account.markdownFormat ? markdownToText(contextText) : contextText }
95
+ }];
75
96
  try {
76
97
  await sendMsg({
77
98
  message_type: isGroup ? 'group' : 'private',
@@ -104,7 +125,7 @@ async function sendMedia(isGroup, chatId, mediaUrl) {
104
125
  * Dispatch an incoming message to the AI for processing
105
126
  */
106
127
  export async function dispatchMessage(params) {
107
- const { chatType, chatId, senderId, senderName, messageId, content, media, timestamp } = params;
128
+ let { chatType, chatId, senderId, senderName, messageId, content, media, timestamp, targetId } = params;
108
129
  const runtime = getRuntime();
109
130
  if (!runtime) {
110
131
  log.warn('dispatch', `Plugin runtime not available`);
@@ -116,31 +137,64 @@ export async function dispatchMessage(params) {
116
137
  return;
117
138
  }
118
139
  const isGroup = chatType === 'group';
119
- const peerId = isGroup ? `group:${chatId}` : senderId;
120
- const route = runtime.channel.routing.resolveAgentRoute({
140
+ const config = context.account;
141
+ // At 模式处理
142
+ if (isGroup) {
143
+ const isMention = mention(content, chatId, targetId);
144
+ if (!isMention) {
145
+ log.debug('dispatch', `Skipping group message (not mentioned)`);
146
+ const groupConfig = getGroupConfig(chatId, config);
147
+ recordPendingHistoryEntry({
148
+ historyMap: historyCache,
149
+ historyKey: chatId,
150
+ limit: groupConfig.historyLimit ?? 20,
151
+ entry: {
152
+ sender: `${senderName}(${senderId})`,
153
+ body: content,
154
+ timestamp: timestamp,
155
+ messageId: messageId,
156
+ },
157
+ });
158
+ return;
159
+ }
160
+ }
161
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
121
162
  cfg: context.cfg,
122
163
  channel: CHANNEL_ID,
164
+ accountId: context.accountId,
123
165
  peer: {
124
- kind: 'group',
125
- id: peerId,
166
+ kind: isGroup ? "group" : "direct",
167
+ id: chatId,
126
168
  },
169
+ runtime: runtime.channel,
170
+ sessionStore: context.cfg.session?.store
127
171
  });
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({
172
+ // 终止信号
173
+ const session = getSession(route.sessionKey);
174
+ if (session.abortController) {
175
+ session.abortController.abort();
176
+ session.aborted = true;
177
+ log.info('dispatch', `Aborted previous session`);
178
+ }
179
+ if (isGroup) {
180
+ const groupConfig = getGroupConfig(chatId, config);
181
+ content = buildPendingHistoryContextFromMap({
182
+ historyMap: historyCache,
183
+ historyKey: chatId,
184
+ limit: groupConfig.historyLimit ?? 20,
185
+ currentMessage: content,
186
+ formatEntry: (e) => `${e.sender}: ${e.body}`,
187
+ });
188
+ }
189
+ const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
190
+ const { storePath, body } = buildEnvelope({
131
191
  channel: CHANNEL_ID,
132
- from: senderName || senderId,
192
+ from: fromLabel,
133
193
  body: content,
134
194
  timestamp,
135
- chatType: isGroup ? 'group' : 'direct',
136
- sender: {
137
- id: senderId,
138
- name: senderName,
139
- },
140
- envelope: envelopeOptions,
141
195
  });
142
196
  log.debug('dispatch', `Inbound envelope: ${body}`);
143
- const fromAddress = isGroup ? `qq:group:${chatId}` : `qq:${senderId}`;
197
+ const fromAddress = `qq:${fromLabel}`;
144
198
  const toAddress = `qq:${chatId}`;
145
199
  const ctxPayload = runtime.channel.reply.finalizeInboundContext({
146
200
  Body: body,
@@ -151,6 +205,7 @@ export async function dispatchMessage(params) {
151
205
  SessionKey: route.sessionKey,
152
206
  AccountId: route.accountId,
153
207
  ChatType: isGroup ? 'group' : 'direct',
208
+ ConversationLabel: fromLabel,
154
209
  SenderId: senderId,
155
210
  SenderName: senderName,
156
211
  Provider: CHANNEL_ID,
@@ -164,8 +219,18 @@ export async function dispatchMessage(params) {
164
219
  OriginatingTo: toAddress,
165
220
  });
166
221
  log.info('dispatch', `Dispatching to agent ${route.agentId}, session: ${route.sessionKey}`);
222
+ await runtime.channel.session.recordInboundSession({
223
+ storePath,
224
+ sessionKey: route.sessionKey,
225
+ ctx: ctxPayload,
226
+ onRecordError(err) {
227
+ log.error('dispatch', `Failed to record inbound session: ${err}`);
228
+ },
229
+ });
167
230
  const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(context.cfg, route.agentId);
168
231
  try {
232
+ session.abortController = new AbortController();
233
+ updateSession(route.sessionKey, session);
169
234
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
170
235
  ctx: ctxPayload,
171
236
  cfg: context.cfg,
@@ -184,6 +249,14 @@ export async function dispatchMessage(params) {
184
249
  }
185
250
  },
186
251
  deliver: async (payload, info) => {
252
+ if (session.aborted) {
253
+ session.aborted = false;
254
+ log.info('dispatch', `aborted skipping`);
255
+ return;
256
+ }
257
+ if (isGroup) {
258
+ clearHistoryEntries({ historyMap: historyCache, historyKey: chatId });
259
+ }
187
260
  log.info('dispatch', `deliver(${info.kind}): ${JSON.stringify(payload)}`);
188
261
  if (payload.text && !payload.text.startsWith('MEDIA:')) {
189
262
  await sendText(isGroup, chatId, payload.text);
@@ -205,7 +278,9 @@ export async function dispatchMessage(params) {
205
278
  await sendText(isGroup, chatId, `[错误]\n${String(err)}`);
206
279
  },
207
280
  },
208
- replyOptions: {},
281
+ replyOptions: {
282
+ abortSignal: session.abortController?.signal,
283
+ },
209
284
  });
210
285
  log.info('dispatch', `Dispatch completed`);
211
286
  }
@@ -220,12 +295,17 @@ export async function dispatchMessage(params) {
220
295
  event_type: 2
221
296
  });
222
297
  }
298
+ clearSession(route.sessionKey);
223
299
  }
224
300
  }
225
301
  /**
226
302
  * Handle group message event
227
303
  */
228
304
  export async function handleGroupMessage(event) {
305
+ if (!allow(event.user_id.toString(), event.group_id.toString())) {
306
+ log.debug('dispatch', `Ignoring group message from ${event.user_id}`);
307
+ return;
308
+ }
229
309
  const content = await napCatToOpenClawMessage(event.message);
230
310
  const plainText = await contentToPlainText(content);
231
311
  const media = await contextToMedia(content);
@@ -245,6 +325,10 @@ export async function handleGroupMessage(event) {
245
325
  * Handle private message event
246
326
  */
247
327
  export async function handlePrivateMessage(event) {
328
+ if (!allow(event.user_id.toString())) {
329
+ log.debug('dispatch', `Ignoring message from ${event.user_id}`);
330
+ return;
331
+ }
248
332
  const content = await napCatToOpenClawMessage(event.message);
249
333
  const plainText = await contentToPlainText(content);
250
334
  const media = await contextToMedia(content);
@@ -270,6 +354,10 @@ function extractPokeActionText(rawInfo) {
270
354
  return actionItem?.txt || '戳了戳';
271
355
  }
272
356
  export async function handlePokeEvent(event) {
357
+ if (!allow(event.user_id.toString(), event.group_id?.toString())) {
358
+ log.debug('dispatch', `Poke from ${event.user_id} is not allowed`);
359
+ return;
360
+ }
273
361
  const actionText = extractPokeActionText(event.raw_info);
274
362
  log.info('dispatch', `Poke from ${event.user_id}: ${actionText}`);
275
363
  const pokeMessage = actionText || '戳了戳';
@@ -281,7 +369,77 @@ export async function handlePokeEvent(event) {
281
369
  senderId: String(event.user_id),
282
370
  senderName: String(event.user_id),
283
371
  messageId: `poke_${event.user_id}_${Date.now()}`,
284
- content: `[动作]\n${pokeMessage}`,
372
+ content: `[动作]${pokeMessage}`,
285
373
  timestamp: Date.now(),
374
+ targetId: String(event.target_id),
286
375
  });
287
376
  }
377
+ function getGroupConfig(groupId, config) {
378
+ log.debug('dispatch', `All Custom config: ${JSON.stringify(config.messageGroupsCustom)}`);
379
+ let groupConfig = config.messageGroupsCustom[groupId];
380
+ if (!groupConfig) {
381
+ groupConfig = config.messageGroup;
382
+ log.debug('dispatch', `Use global config: ${JSON.stringify(groupConfig)}`);
383
+ }
384
+ else {
385
+ groupConfig = {
386
+ ...config.messageGroup,
387
+ ...groupConfig,
388
+ };
389
+ }
390
+ log.debug('dispatch', `Final config: ${JSON.stringify(groupConfig)}`);
391
+ return groupConfig;
392
+ }
393
+ function allow(userId, groupId) {
394
+ const context = getContext();
395
+ if (!context) {
396
+ log.warn('dispatch', `No gateway context`);
397
+ return false;
398
+ }
399
+ let config = groupId ? getGroupConfig(groupId, context.account) : context.account.messageDirect;
400
+ return allowJudgment(config, userId);
401
+ }
402
+ function allowJudgment(config, userId) {
403
+ if (config.denyFrom?.includes(userId)) {
404
+ log.debug('dispatch', `User ${userId} is denied`);
405
+ return false;
406
+ }
407
+ if (config.policy === 'allow') {
408
+ log.debug('dispatch', `User ${userId} is allowed`);
409
+ return true;
410
+ }
411
+ if (config.policy === 'deny') {
412
+ log.debug('dispatch', `User ${userId} is denied`);
413
+ return false;
414
+ }
415
+ if (config.allowFrom?.includes(userId)) {
416
+ log.debug('dispatch', `User ${userId} is allowed`);
417
+ return true;
418
+ }
419
+ return false;
420
+ }
421
+ function mention(content, groupId, targetId) {
422
+ const context = getContext();
423
+ if (!context) {
424
+ log.warn('dispatch', `No gateway context`);
425
+ return false;
426
+ }
427
+ let config = getGroupConfig(groupId, context.account);
428
+ const loginInfo = getLoginInfo();
429
+ const isMentionEnabled = !!config?.requireMention;
430
+ const isPokeEnabled = !!config?.requirePoke;
431
+ const isWakeEnabled = !!config?.wakeWord?.trim();
432
+ if (!isMentionEnabled && !isPokeEnabled && !isWakeEnabled) {
433
+ log.debug('dispatch', 'All requires are disabled, returning true by default.');
434
+ return true;
435
+ }
436
+ const requireMention = isMentionEnabled &&
437
+ (content.includes('[提及]@全体成员') ||
438
+ (!!loginInfo?.userId && content.includes(`[提及]@${loginInfo.userId}`)));
439
+ const requirePoke = isPokeEnabled &&
440
+ (content.includes('[动作]') && targetId === loginInfo?.userId);
441
+ const requireWake = isWakeEnabled &&
442
+ content.includes(config.wakeWord ?? "");
443
+ log.debug('dispatch', `Require mention: ${requireMention}, require poke: ${requirePoke}, require wake: ${requireWake}`);
444
+ return requireMention || requirePoke || requireWake;
445
+ }
@@ -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,38 @@ 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
+ markdownFormat: boolean;
284
+ messageDirect: QQAllowConfig;
285
+ messageGroup: QQGroupConfig;
286
+ messageGroupsCustom: Record<string, QQGroupConfig>;
287
+ }
288
+ export interface QQGroupConfig extends QQAllowConfig {
289
+ requireMention: boolean;
290
+ requirePoke: boolean;
291
+ historyLimit: number;
292
+ wakeWord?: string;
293
+ }
294
+ export interface QQAllowConfig {
295
+ policy: "allow" | "deny" | "allowlist";
296
+ allowFrom: string[];
297
+ denyFrom: string[];
265
298
  }
266
299
  export type QQProbe = {
267
300
  ok: boolean;
268
301
  status?: number | null;
269
302
  error?: string | null;
270
303
  };
304
+ export type QQSession = {
305
+ abortController?: AbortController;
306
+ aborted?: boolean;
307
+ };
308
+ export type QQLoginInfo = {
309
+ userId: string;
310
+ nickname: string;
311
+ };
@@ -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.5.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
  }