@izhimu/qq 0.4.0 → 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
@@ -144,6 +144,10 @@ openclaw gateway restart
144
144
  | `wsUrl` | `string` | 是 | - | NapCat WebSocket 地址 |
145
145
  | `accessToken` | `string` | 否 | `""` | 访问令牌(如配置了认证) |
146
146
  | `enabled` | `boolean` | 否 | `true` | 是否启用该账号 |
147
+ | `markdownFormat` | `boolean` | 否 | `true` | 是否启用 Markdown 格式化转换 |
148
+ | `messageDirect` | `object` | 否 | - | 私聊全局配置(策略、黑白名单) |
149
+ | `messageGroup` | `object` | 否 | - | 群组全局配置(@响应、戳一戳、唤醒词等) |
150
+ | `messageGroupsCustom` | `object` | 否 | `{}` | 特定群组的独立配置 |
147
151
 
148
152
  ### 配置示例
149
153
 
@@ -153,7 +157,19 @@ openclaw gateway restart
153
157
  "qq": {
154
158
  "wsUrl": "ws://127.0.0.1:3001",
155
159
  "accessToken": "your-token",
156
- "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
+ }
157
173
  }
158
174
  }
159
175
  }
@@ -262,6 +278,7 @@ openclaw-channel-qq/
262
278
  | `reply` | ✓ | ✓ | 消息回复 |
263
279
  | `record` | ✓ | ✓ | 语音消息 |
264
280
  | `file` | ✓ | ✓ | 文件 |
281
+ | `video` | ✓ | - | 视频消息 |
265
282
  | `json` | ✓ | - | JSON 富文本 |
266
283
 
267
284
  ### OneBot 11 接口
@@ -374,6 +391,24 @@ npm run build
374
391
 
375
392
  ## 更新日志
376
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
+
377
412
  ### [0.4.0] - 2026-03-07
378
413
 
379
414
  #### 新增
@@ -4,7 +4,7 @@
4
4
  */
5
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, setLoginInfo } 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";
@@ -250,8 +250,18 @@ async function outboundSend(ctx) {
250
250
  const chatType = type === "group" ? "group" : "private";
251
251
  const chatId = id || to;
252
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
+ }
253
263
  if (text) {
254
- content.push({ type: "text", text: markdownToText(text) });
264
+ content.push({ type: "text", text: context.account.markdownFormat ? markdownToText(text) : text });
255
265
  }
256
266
  if (mediaUrl) {
257
267
  content.push(buildMediaMessage(mediaUrl));
@@ -15,10 +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>;
22
- groupAtMode: z.ZodDefault<z.ZodBoolean>;
23
- groupHistoryLimit: z.ZodDefault<z.ZodNumber>;
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>>>>;
24
80
  }, z.core.$strip>;
@@ -23,8 +23,22 @@ 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
+ 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 ?? {},
28
42
  };
29
43
  }
30
44
  /**
@@ -35,10 +49,26 @@ const wsUrlSchema = z.string()
35
49
  .regex(wsUrlRegex, { message: "Invalid WebSocket URL format. Expected: ws://host:port or wss://host:port" })
36
50
  .default("ws://127.0.0.1:3001")
37
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("群组全局配置");
38
66
  export const QQConfigSchema = z.object({
39
67
  wsUrl: wsUrlSchema,
40
68
  accessToken: z.string().default("access-token").describe("NapCat Websocket Token"),
41
69
  enable: z.boolean().default(true).describe("是否启用"),
42
- groupAtMode: z.boolean().default(true).describe("群组响应模式:默认启用,只有在被@时才会响应"),
43
- groupHistoryLimit: z.number().default(20).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(),
44
74
  });
@@ -20,7 +20,7 @@ async function contentToPlainText(content) {
20
20
  return c.text;
21
21
  case 'at':
22
22
  const target = c.isAll ? '@全体成员' : `@${c.userId}`;
23
- return `[AT]${target}`;
23
+ return `[提及]${target}`;
24
24
  case 'image':
25
25
  return `[图片]${c.url}`;
26
26
  case 'audio':
@@ -83,8 +83,16 @@ async function contextToMedia(content) {
83
83
  return;
84
84
  }
85
85
  async function sendText(isGroup, chatId, text) {
86
- const cleanText = text.replace(/NO_REPLY\s*$/, '');
87
- 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
+ }];
88
96
  try {
89
97
  await sendMsg({
90
98
  message_type: isGroup ? 'group' : 'private',
@@ -131,17 +139,15 @@ export async function dispatchMessage(params) {
131
139
  const isGroup = chatType === 'group';
132
140
  const config = context.account;
133
141
  // 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) {
142
+ if (isGroup) {
143
+ const isMention = mention(content, chatId, targetId);
144
+ if (!isMention) {
140
145
  log.debug('dispatch', `Skipping group message (not mentioned)`);
146
+ const groupConfig = getGroupConfig(chatId, config);
141
147
  recordPendingHistoryEntry({
142
148
  historyMap: historyCache,
143
149
  historyKey: chatId,
144
- limit: config.groupHistoryLimit,
150
+ limit: groupConfig.historyLimit ?? 20,
145
151
  entry: {
146
152
  sender: `${senderName}(${senderId})`,
147
153
  body: content,
@@ -171,10 +177,11 @@ export async function dispatchMessage(params) {
171
177
  log.info('dispatch', `Aborted previous session`);
172
178
  }
173
179
  if (isGroup) {
180
+ const groupConfig = getGroupConfig(chatId, config);
174
181
  content = buildPendingHistoryContextFromMap({
175
182
  historyMap: historyCache,
176
183
  historyKey: chatId,
177
- limit: config.groupHistoryLimit,
184
+ limit: groupConfig.historyLimit ?? 20,
178
185
  currentMessage: content,
179
186
  formatEntry: (e) => `${e.sender}: ${e.body}`,
180
187
  });
@@ -295,6 +302,10 @@ export async function dispatchMessage(params) {
295
302
  * Handle group message event
296
303
  */
297
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
+ }
298
309
  const content = await napCatToOpenClawMessage(event.message);
299
310
  const plainText = await contentToPlainText(content);
300
311
  const media = await contextToMedia(content);
@@ -314,6 +325,10 @@ export async function handleGroupMessage(event) {
314
325
  * Handle private message event
315
326
  */
316
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
+ }
317
332
  const content = await napCatToOpenClawMessage(event.message);
318
333
  const plainText = await contentToPlainText(content);
319
334
  const media = await contextToMedia(content);
@@ -339,6 +354,10 @@ function extractPokeActionText(rawInfo) {
339
354
  return actionItem?.txt || '戳了戳';
340
355
  }
341
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
+ }
342
361
  const actionText = extractPokeActionText(event.raw_info);
343
362
  log.info('dispatch', `Poke from ${event.user_id}: ${actionText}`);
344
363
  const pokeMessage = actionText || '戳了戳';
@@ -355,3 +374,72 @@ export async function handlePokeEvent(event) {
355
374
  targetId: String(event.target_id),
356
375
  });
357
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
+ }
@@ -280,8 +280,21 @@ export interface QQConfig {
280
280
  wsUrl: string;
281
281
  accessToken?: string;
282
282
  enabled: boolean;
283
- groupAtMode: boolean;
284
- groupHistoryLimit: number;
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[];
285
298
  }
286
299
  export type QQProbe = {
287
300
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izhimu/qq",
3
- "version": "0.4.0",
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",