@izhimu/qq 0.5.1 → 0.6.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.
Files changed (38) hide show
  1. package/README.md +12 -16
  2. package/dist/index.d.ts +5 -11
  3. package/dist/index.js +9 -18
  4. package/dist/src/adapters/message.d.ts +15 -4
  5. package/dist/src/adapters/message.js +179 -124
  6. package/dist/src/channel.d.ts +2 -7
  7. package/dist/src/channel.js +231 -312
  8. package/dist/src/core/auth.d.ts +67 -0
  9. package/dist/src/core/auth.js +154 -0
  10. package/dist/src/core/config.d.ts +5 -7
  11. package/dist/src/core/config.js +6 -8
  12. package/dist/src/core/connection.d.ts +6 -5
  13. package/dist/src/core/connection.js +17 -70
  14. package/dist/src/core/dispatch.d.ts +7 -54
  15. package/dist/src/core/dispatch.js +210 -398
  16. package/dist/src/core/event-handler.d.ts +42 -0
  17. package/dist/src/core/event-handler.js +171 -0
  18. package/dist/src/core/request.d.ts +3 -8
  19. package/dist/src/core/request.js +13 -126
  20. package/dist/src/core/runtime.d.ts +2 -11
  21. package/dist/src/core/runtime.js +0 -47
  22. package/dist/src/runtime.d.ts +3 -0
  23. package/dist/src/runtime.js +3 -0
  24. package/dist/src/setup-surface.d.ts +2 -0
  25. package/dist/src/setup-surface.js +59 -0
  26. package/dist/src/types/index.d.ts +69 -25
  27. package/dist/src/types/index.js +3 -4
  28. package/dist/src/utils/cqcode.d.ts +0 -9
  29. package/dist/src/utils/cqcode.js +0 -17
  30. package/dist/src/utils/index.d.ts +0 -17
  31. package/dist/src/utils/index.js +17 -154
  32. package/dist/src/utils/log.js +2 -2
  33. package/dist/src/utils/markdown.d.ts +5 -0
  34. package/dist/src/utils/markdown.js +57 -5
  35. package/openclaw.plugin.json +3 -2
  36. package/package.json +9 -11
  37. package/dist/src/onboarding.d.ts +0 -10
  38. package/dist/src/onboarding.js +0 -98
@@ -0,0 +1,42 @@
1
+ /**
2
+ * QQ Event Handler Module
3
+ *
4
+ * 统一事件处理入口,提供:
5
+ * - 事件上下文构建 (buildEventContext)
6
+ * - 统一授权检查 (checkEventAuthorization)
7
+ * - 事件处理器工厂 (createQQEventHandler)
8
+ */
9
+ import type { NapCatEvent, InboundMessage } from "../types";
10
+ import type { QQAccount } from "../types";
11
+ /**
12
+ * 构建入站消息
13
+ * @param account
14
+ * @param event
15
+ */
16
+ export declare function buildInboundMessage(account: QQAccount, event: NapCatEvent): Promise<InboundMessage | null>;
17
+ /**
18
+ * 检查事件是否被授权
19
+ */
20
+ export declare function isEventAuthorized(ctx: InboundMessage): boolean;
21
+ /**
22
+ * 创建 QQ 事件处理器
23
+ *
24
+ * 这是统一的事件处理入口点,负责:
25
+ * 1. 构建事件上下文
26
+ * 2. 执行授权检查
27
+ * 3. 更新状态
28
+ * 4. 路由到具体处理器
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * const handler = createQQEventHandler({
33
+ * runtime,
34
+ * cfg: context.cfg,
35
+ * accountId: context.accountId,
36
+ * connection,
37
+ * });
38
+ *
39
+ * connection.on("event", handler);
40
+ * ```
41
+ */
42
+ export declare function createQQEventHandler(account: QQAccount, handler: (msg: InboundMessage) => Promise<void>): (event: NapCatEvent) => Promise<void>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * QQ Event Handler Module
3
+ *
4
+ * 统一事件处理入口,提供:
5
+ * - 事件上下文构建 (buildEventContext)
6
+ * - 统一授权检查 (checkEventAuthorization)
7
+ * - 事件处理器工厂 (createQQEventHandler)
8
+ */
9
+ import { resolveQQCommandAuthorization, getQQConfigByChatType } from "./auth.js";
10
+ import { inboundMessageAdapter, formatContentToText, hasMediaContent, extractMedia, } from "../adapters/message.js";
11
+ import { generateMessageId, Logger as log } from "../utils/index.js";
12
+ /**
13
+ * 构建入站消息
14
+ * @param account
15
+ * @param event
16
+ */
17
+ export async function buildInboundMessage(account, event) {
18
+ // 处理消息事件
19
+ if (event.post_type === "message") {
20
+ return buildMessageEventContext(event, account);
21
+ }
22
+ // 处理通知事件
23
+ if (event.post_type === "notice") {
24
+ const noticeEvent = event;
25
+ const isPokeEvent = noticeEvent.notice_type === "poke" ||
26
+ (noticeEvent.notice_type === "notify" && noticeEvent.sub_type === "poke");
27
+ if (isPokeEvent) {
28
+ return buildPokeEventContext(noticeEvent, account);
29
+ }
30
+ }
31
+ // 不支持的事件类型
32
+ log.debug("event-handler", `Unhandled event type: ${event.post_type}`);
33
+ return null;
34
+ }
35
+ /**
36
+ * 构建消息事件上下文
37
+ */
38
+ async function buildMessageEventContext(event, account) {
39
+ // 过滤空消息
40
+ if (!event.raw_message || event.raw_message.trim() === "") {
41
+ log.debug("event-handler", "Ignored empty message");
42
+ return null;
43
+ }
44
+ const isGroup = event.message_type === "group";
45
+ const senderId = event.user_id.toString();
46
+ const groupId = event.group_id?.toString();
47
+ // 获取配置并进行授权检查
48
+ const qqConfig = getQQConfigByChatType(isGroup, groupId, account);
49
+ const authorization = resolveQQCommandAuthorization({
50
+ senderId,
51
+ qqConfig,
52
+ });
53
+ // 解析消息内容
54
+ const content = await inboundMessageAdapter(event.message);
55
+ const plainText = formatContentToText(content);
56
+ const media = hasMediaContent(content) ? extractMedia(content) : undefined;
57
+ return {
58
+ targetId: account.accountId,
59
+ messageId: event.message_id?.toString() ?? generateMessageId(),
60
+ senderId,
61
+ senderName: event.sender?.nickname || event.sender?.card,
62
+ text: plainText,
63
+ timestamp: event.time * 1000,
64
+ isGroup,
65
+ groupId,
66
+ hasMedia: !!media,
67
+ media,
68
+ authorization: {
69
+ isAuthorizedSender: authorization.isAuthorizedSender,
70
+ denialReason: authorization.denialReason,
71
+ },
72
+ };
73
+ }
74
+ /**
75
+ * 构建戳一戳事件上下文
76
+ */
77
+ function buildPokeEventContext(event, account) {
78
+ const isGroup = !!event.group_id;
79
+ const senderId = event.user_id.toString();
80
+ const groupId = event.group_id?.toString();
81
+ // 获取配置并进行授权检查
82
+ const qqConfig = getQQConfigByChatType(isGroup, groupId, account);
83
+ const authorization = resolveQQCommandAuthorization({
84
+ senderId,
85
+ qqConfig,
86
+ });
87
+ // 提取戳一戳动作文本
88
+ const actionText = extractPokeActionText(event.raw_info);
89
+ const pokeContent = `[动作]${actionText || "戳了戳"}`;
90
+ return {
91
+ targetId: event.target_id.toString(),
92
+ messageId: generateMessageId(),
93
+ senderId,
94
+ senderName: senderId,
95
+ text: pokeContent,
96
+ timestamp: event.time * 1000,
97
+ isGroup,
98
+ groupId,
99
+ hasMedia: false,
100
+ authorization: {
101
+ isAuthorizedSender: authorization.isAuthorizedSender,
102
+ denialReason: authorization.denialReason,
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * 提取戳一戳动作文本
108
+ */
109
+ function extractPokeActionText(rawInfo) {
110
+ if (!rawInfo)
111
+ return "戳了戳";
112
+ const actionItem = rawInfo.find((item) => item.type === "nor" && item.txt);
113
+ return actionItem?.txt || "戳了戳";
114
+ }
115
+ // =============================================================================
116
+ // Authorization Check
117
+ // =============================================================================
118
+ /**
119
+ * 检查事件是否被授权
120
+ */
121
+ export function isEventAuthorized(ctx) {
122
+ if (!ctx.authorization) {
123
+ return false;
124
+ }
125
+ if (!ctx.authorization.isAuthorizedSender) {
126
+ log.info("event-handler", `Authorization denied for ${ctx.senderId}: ${ctx.authorization.denialReason}`);
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+ // =============================================================================
132
+ // Event Handler Factory
133
+ // =============================================================================
134
+ /**
135
+ * 创建 QQ 事件处理器
136
+ *
137
+ * 这是统一的事件处理入口点,负责:
138
+ * 1. 构建事件上下文
139
+ * 2. 执行授权检查
140
+ * 3. 更新状态
141
+ * 4. 路由到具体处理器
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * const handler = createQQEventHandler({
146
+ * runtime,
147
+ * cfg: context.cfg,
148
+ * accountId: context.accountId,
149
+ * connection,
150
+ * });
151
+ *
152
+ * connection.on("event", handler);
153
+ * ```
154
+ */
155
+ export function createQQEventHandler(account, handler) {
156
+ return async (event) => {
157
+ log.debug("event-handler", `Received event: ${event.post_type}`);
158
+ // 1. 构建事件上下文
159
+ const msg = await buildInboundMessage(account, event);
160
+ if (!msg) {
161
+ return;
162
+ }
163
+ // 2. 授权检查
164
+ if (!isEventAuthorized(msg)) {
165
+ return;
166
+ }
167
+ // 3. 路由到具体处理器 - 调用 dispatchMessage
168
+ await handler(msg);
169
+ return;
170
+ };
171
+ }
@@ -1,27 +1,22 @@
1
- import type { GetFileReq, GetFileResp, GetMsgReq, GetMsgResp, GetStatusResp, GetLoginInfoResp, NapCatResp, SendMsgReq, SendMsgResp, SetInputStatusReq, GetFriendListResp, GetGroupListResp } from "../types";
1
+ import type { GetFileReq, GetFileResp, GetMsgReq, GetMsgResp, GetStatusResp, GetLoginInfoResp, NapCatResp, SendMsgReq, SendMsgResp, SetInputStatusReq, GetFriendListResp, GetGroupListResp, NapCatEvent, QQAccount, InboundMessage } from "../types";
2
2
  /**
3
3
  * 事件监听
4
- * @param event
5
4
  */
6
- export declare function eventListener(event: any): Promise<void>;
5
+ export declare function eventListener(account: QQAccount, event: NapCatEvent, handler: (msg: InboundMessage) => Promise<void>): Promise<void>;
7
6
  /**
8
- * 发送消息(带限流)
9
- * @param params
7
+ * 发送消息
10
8
  */
11
9
  export declare function sendMsg(params: SendMsgReq): Promise<NapCatResp<SendMsgResp>>;
12
10
  /**
13
11
  * 获取消息
14
- * @param params
15
12
  */
16
13
  export declare function getMsg(params: GetMsgReq): Promise<NapCatResp<GetMsgResp>>;
17
14
  /**
18
15
  * 获取文件
19
- * @param params
20
16
  */
21
17
  export declare function getFile(params: GetFileReq): Promise<NapCatResp<GetFileResp>>;
22
18
  /**
23
19
  * 设置输入状态
24
- * @param params
25
20
  */
26
21
  export declare function setInputStatus(params: SetInputStatusReq): Promise<NapCatResp<void>>;
27
22
  /**
@@ -1,171 +1,58 @@
1
- import pLimit from 'p-limit';
2
1
  import { Logger as log } from "../utils/index.js";
3
- import { setContextStatus, getContext, getConnection } from "./runtime.js";
4
- import { handleGroupMessage, handlePrivateMessage, handlePokeEvent } from "./dispatch.js";
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);
2
+ import { createQQEventHandler } from "./event-handler.js";
3
+ import { sendRequest } from "./connection.js";
11
4
  /**
12
5
  * 事件监听
13
- * @param event
14
6
  */
15
- export async function eventListener(event) {
7
+ export async function eventListener(account, event, handler) {
16
8
  log.debug("request", `Received event: ${event.post_type}`);
17
- const context = getContext();
18
- if (!context) {
19
- log.warn("request", `No gateway context`);
20
- return;
21
- }
22
- const connection = getConnection();
23
- if (!connection) {
24
- log.warn("request", `No connection available`);
25
- return;
26
- }
27
- switch (event.post_type) {
28
- case "message":
29
- // 过滤空消息
30
- if (!event.raw_message || event.raw_message === '') {
31
- log.debug("request", `Ignored empty message`);
32
- break;
33
- }
34
- setContextStatus({
35
- lastInboundAt: Date.now(),
36
- });
37
- if (event.message_type === "group" && event.group_id) {
38
- await handleGroupMessage({
39
- time: event.time,
40
- self_id: event.self_id,
41
- message_id: event.message_id ?? 0,
42
- group_id: event.group_id,
43
- user_id: event.user_id,
44
- message: event.message ?? [],
45
- raw_message: event.raw_message ?? '',
46
- sender: event.sender,
47
- });
48
- }
49
- else if (event.message_type === "private") {
50
- await handlePrivateMessage({
51
- time: event.time,
52
- self_id: event.self_id,
53
- message_id: event.message_id ?? 0,
54
- user_id: event.user_id,
55
- message: event.message ?? [],
56
- raw_message: event.raw_message ?? '',
57
- sender: event.sender,
58
- });
59
- }
60
- break;
61
- case "notice":
62
- if (event.target_id) {
63
- const isPokeEvent = event.notice_type === "poke" ||
64
- (event.notice_type === "notify" && event.sub_type === "poke");
65
- if (isPokeEvent) {
66
- await handlePokeEvent({
67
- user_id: event.user_id,
68
- target_id: event.target_id,
69
- group_id: event.group_id,
70
- raw_info: event.raw_info,
71
- });
72
- }
73
- }
74
- break;
75
- default:
76
- log.debug("request", `Unhandled event type: ${event.post_type}`);
77
- }
9
+ await createQQEventHandler(account, handler)(event);
78
10
  }
79
11
  /**
80
- * 发送消息(带限流)
81
- * @param params
12
+ * 发送消息
82
13
  */
83
14
  export async function sendMsg(params) {
84
- const connection = getConnection();
85
- if (!connection) {
86
- log.warn("request", `No connection available`);
87
- return failResp();
88
- }
89
- // 使用限流器控制并发,避免触发 NapCat API 限流
90
- return sendMsgLimiter(() => connection.sendRequest("send_msg", params));
15
+ return sendRequest("send_msg", params);
91
16
  }
92
17
  /**
93
18
  * 获取消息
94
- * @param params
95
19
  */
96
20
  export async function getMsg(params) {
97
- const connection = getConnection();
98
- if (!connection) {
99
- log.warn("request", `No connection available`);
100
- return failResp();
101
- }
102
- return connection.sendRequest("get_msg", params);
21
+ return sendRequest("get_msg", params);
103
22
  }
104
23
  /**
105
24
  * 获取文件
106
- * @param params
107
25
  */
108
26
  export async function getFile(params) {
109
- const connection = getConnection();
110
- if (!connection) {
111
- log.warn("request", `No connection available`);
112
- return failResp();
113
- }
114
- return connection.sendRequest("get_file", params);
27
+ return sendRequest("get_file", params);
115
28
  }
116
29
  /**
117
30
  * 设置输入状态
118
- * @param params
119
31
  */
120
32
  export async function setInputStatus(params) {
121
- const connection = getConnection();
122
- if (!connection) {
123
- log.warn("request", `No connection available`);
124
- return failResp();
125
- }
126
- return connection.sendRequest("set_input_status", params);
33
+ return sendRequest("set_input_status", params);
127
34
  }
128
35
  /**
129
36
  * 获取状态
130
37
  */
131
38
  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");
39
+ return sendRequest("get_status");
138
40
  }
139
41
  /**
140
42
  * 获取登录信息
141
43
  */
142
44
  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");
45
+ return sendRequest("get_login_info");
149
46
  }
150
47
  /**
151
48
  * 获取好友列表
152
49
  */
153
50
  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");
51
+ return sendRequest("get_friend_list");
160
52
  }
161
53
  /**
162
54
  * 获取群列表
163
55
  */
164
56
  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");
57
+ return sendRequest("get_group_list");
171
58
  }
@@ -2,21 +2,12 @@
2
2
  * Plugin Runtime Storage
3
3
  * Stores the PluginRuntime for access in gateway handlers
4
4
  */
5
- import type { ChannelAccountSnapshot, ChannelGatewayContext, HistoryEntry, PluginRuntime } from "openclaw/plugin-sdk";
6
- import { QQConfig, QQLoginInfo, QQSession } from "../types";
5
+ import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
6
+ import { QQLoginInfo } from "../types";
7
7
  import { ConnectionManager } from "./connection.js";
8
- export declare function setRuntime(next: PluginRuntime): void;
9
- export declare function getRuntime(): PluginRuntime | null;
10
- export declare function setContext(next: ChannelGatewayContext<QQConfig>): void;
11
- export declare function getContext(): ChannelGatewayContext<QQConfig> | null;
12
- export declare function clearContext(): void;
13
- export declare function setContextStatus(next: Omit<ChannelAccountSnapshot, 'accountId'>): void;
14
8
  export declare function setConnection(next: ConnectionManager): void;
15
9
  export declare function getConnection(): ConnectionManager | null;
16
10
  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
11
  export declare function setLoginInfo(next: QQLoginInfo): void;
21
12
  export declare function getLoginInfo(): QQLoginInfo;
22
13
  export declare const historyCache: Map<string, HistoryEntry[]>;
@@ -3,34 +3,6 @@
3
3
  * Stores the PluginRuntime for access in gateway handlers
4
4
  */
5
5
  // =============================================================================
6
- // Runtime
7
- // =============================================================================
8
- let runtime = null;
9
- export function setRuntime(next) {
10
- runtime = next;
11
- }
12
- export function getRuntime() {
13
- return runtime;
14
- }
15
- // =============================================================================
16
- // Context
17
- // =============================================================================
18
- let context = null;
19
- export function setContext(next) {
20
- context = next;
21
- }
22
- export function getContext() {
23
- return context;
24
- }
25
- export function clearContext() {
26
- context = null;
27
- }
28
- export function setContextStatus(next) {
29
- if (context) {
30
- context.setStatus(next);
31
- }
32
- }
33
- // =============================================================================
34
6
  // Connection
35
7
  // =============================================================================
36
8
  let connection = null;
@@ -44,25 +16,6 @@ export function clearConnection() {
44
16
  connection = null;
45
17
  }
46
18
  // =============================================================================
47
- // Session
48
- // =============================================================================
49
- const sessionMap = new Map();
50
- export function getSession(sessionKey) {
51
- let session = sessionMap.get(sessionKey);
52
- if (session) {
53
- return session;
54
- }
55
- session = {};
56
- sessionMap.set(sessionKey, session);
57
- return session;
58
- }
59
- export function updateSession(sessionKey, session) {
60
- sessionMap.set(sessionKey, session);
61
- }
62
- export function clearSession(sessionKey) {
63
- sessionMap.delete(sessionKey);
64
- }
65
- // =============================================================================
66
19
  // LoginInfo
67
20
  // =============================================================================
68
21
  const loginInfo = {
@@ -0,0 +1,3 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ declare const setQQRuntime: (next: PluginRuntime) => void, getQQRuntime: () => PluginRuntime;
3
+ export { getQQRuntime, setQQRuntime };
@@ -0,0 +1,3 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
+ const { setRuntime: setQQRuntime, getRuntime: getQQRuntime } = createPluginRuntimeStore("QQ runtime not initialized");
3
+ export { getQQRuntime, setQQRuntime };
@@ -0,0 +1,2 @@
1
+ import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
2
+ export declare const qqSetupWizard: ChannelSetupWizard;
@@ -0,0 +1,59 @@
1
+ import { createStandardChannelSetupStatus, applySetupAccountConfigPatch } from "openclaw/plugin-sdk/setup";
2
+ import { QQ_CHANNEL, listQQAccountIds, resolveQQAccount } from "./core/config";
3
+ export const qqSetupWizard = {
4
+ channel: QQ_CHANNEL,
5
+ status: createStandardChannelSetupStatus({
6
+ channelLabel: "QQ Chat",
7
+ configuredLabel: "configured",
8
+ unconfiguredLabel: "needs service account",
9
+ configuredHint: "configured",
10
+ unconfiguredHint: "needs auth",
11
+ includeStatusLine: true,
12
+ resolveConfigured: ({ cfg }) => listQQAccountIds(cfg).some((accountId) => resolveQQAccount({ cfg, accountId }).token !== "none"),
13
+ }),
14
+ introNote: {
15
+ title: "QQ Chat setup",
16
+ lines: [
17
+ "1) 确保已安装 NapCat: https://github.com/NapNeko/NapCatQQ",
18
+ "2) 在 NapCat 配置中启用 WebSocket (正向 WS)",
19
+ "3) 默认地址: ws://localhost:3001",
20
+ "4) 如需访问控制,可设置 token",
21
+ "",
22
+ "NapCat 文档: https://napneko.github.io/",
23
+ ]
24
+ },
25
+ credentials: [],
26
+ textInputs: [
27
+ {
28
+ inputKey: "url",
29
+ message: "请输入 NapCat WebSocket URL",
30
+ placeholder: "ws://localhost:3001",
31
+ shouldPrompt: ({ currentValue }) => !currentValue,
32
+ validate: ({ value }) => (value ? undefined : "Required"),
33
+ normalizeValue: ({ value }) => String(value).trim(),
34
+ applySet: async ({ cfg, accountId, value }) => applySetupAccountConfigPatch({
35
+ cfg,
36
+ channelKey: QQ_CHANNEL,
37
+ accountId,
38
+ patch: {
39
+ wsUrl: value,
40
+ }
41
+ }),
42
+ }, {
43
+ inputKey: "token",
44
+ message: "请输入 Access Token",
45
+ placeholder: "ws://localhost:3001",
46
+ shouldPrompt: ({ currentValue }) => !currentValue,
47
+ validate: ({ value }) => (value ? undefined : "Required"),
48
+ normalizeValue: ({ value }) => String(value).trim(),
49
+ applySet: async ({ cfg, accountId, value }) => applySetupAccountConfigPatch({
50
+ cfg,
51
+ channelKey: QQ_CHANNEL,
52
+ accountId,
53
+ patch: {
54
+ wsUrl: value,
55
+ }
56
+ }),
57
+ }
58
+ ]
59
+ };