@izhimu/qq 0.1.1

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.
@@ -0,0 +1,115 @@
1
+ import { Logger as log } from "../utils/index.js";
2
+ import { setContextStatus, getContext, getConnection } from "./runtime.js";
3
+ import { handleGroupMessage, handlePrivateMessage, handlePokeEvent } from "./dispatch.js";
4
+ import { failResp } from "./connection.js";
5
+ /**
6
+ * 事件监听
7
+ * @param event
8
+ */
9
+ export async function eventListener(event) {
10
+ log.debug("request", `Received event: ${event.post_type}`);
11
+ const context = getContext();
12
+ if (!context) {
13
+ log.warn("request", `No gateway context`);
14
+ return;
15
+ }
16
+ const connection = getConnection();
17
+ if (!connection) {
18
+ log.warn("request", `No connection available`);
19
+ return;
20
+ }
21
+ switch (event.post_type) {
22
+ case "message":
23
+ setContextStatus({
24
+ lastInboundAt: Date.now(),
25
+ });
26
+ if (event.message_type === "group" && event.group_id) {
27
+ await handleGroupMessage({
28
+ time: event.time,
29
+ self_id: event.self_id,
30
+ message_id: event.message_id ?? 0,
31
+ group_id: event.group_id,
32
+ user_id: event.user_id,
33
+ message: event.message ?? [],
34
+ raw_message: event.raw_message ?? '',
35
+ sender: event.sender,
36
+ });
37
+ }
38
+ else if (event.message_type === "private") {
39
+ await handlePrivateMessage({
40
+ time: event.time,
41
+ self_id: event.self_id,
42
+ message_id: event.message_id ?? 0,
43
+ user_id: event.user_id,
44
+ message: event.message ?? [],
45
+ raw_message: event.raw_message ?? '',
46
+ sender: event.sender,
47
+ });
48
+ }
49
+ break;
50
+ case "notice":
51
+ if (event.target_id) {
52
+ const isPokeEvent = event.notice_type === "poke" ||
53
+ (event.notice_type === "notify" && event.sub_type === "poke");
54
+ if (isPokeEvent) {
55
+ await handlePokeEvent({
56
+ user_id: event.user_id,
57
+ target_id: event.target_id,
58
+ group_id: event.group_id,
59
+ raw_info: event.raw_info,
60
+ });
61
+ }
62
+ }
63
+ break;
64
+ default:
65
+ log.debug("request", `Unhandled event type: ${event.post_type}`);
66
+ }
67
+ }
68
+ /**
69
+ * 发送消息
70
+ * @param params
71
+ */
72
+ export async function sendMsg(params) {
73
+ const connection = getConnection();
74
+ if (!connection) {
75
+ log.warn("request", `No connection available`);
76
+ return failResp();
77
+ }
78
+ return connection.sendRequest("send_msg", params);
79
+ }
80
+ /**
81
+ * 获取消息
82
+ * @param params
83
+ */
84
+ export async function getMsg(params) {
85
+ const connection = getConnection();
86
+ if (!connection) {
87
+ log.warn("request", `No connection available`);
88
+ return failResp();
89
+ }
90
+ return connection.sendRequest("get_msg", params);
91
+ }
92
+ /**
93
+ * 获取文件
94
+ * @param params
95
+ */
96
+ export async function getFile(params) {
97
+ const connection = getConnection();
98
+ if (!connection) {
99
+ log.warn("request", `No connection available`);
100
+ return failResp();
101
+ }
102
+ return connection.sendRequest("get_file", params);
103
+ }
104
+ /**
105
+ * 设置输入状态
106
+ * @param params
107
+ */
108
+ export async function setInputStatus(params) {
109
+ const connection = getConnection();
110
+ if (!connection) {
111
+ log.warn("request", `No connection available`);
112
+ return failResp();
113
+ }
114
+ return connection.sendRequest("set_input_status", params);
115
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Plugin Runtime Storage
3
+ * Stores the PluginRuntime for access in gateway handlers
4
+ */
5
+ import type { ChannelAccountSnapshot, ChannelGatewayContext, PluginRuntime } from "openclaw/plugin-sdk";
6
+ import type { QQConfig } from "../types/index.js";
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
+ export declare function setConnection(next: ConnectionManager): void;
15
+ export declare function getConnection(): ConnectionManager | null;
16
+ export declare function clearConnection(): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Plugin Runtime Storage
3
+ * Stores the PluginRuntime for access in gateway handlers
4
+ */
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({
31
+ ...context.getStatus(),
32
+ ...next,
33
+ });
34
+ }
35
+ }
36
+ // =============================================================================
37
+ // Connection
38
+ // =============================================================================
39
+ let connection = null;
40
+ export function setConnection(next) {
41
+ connection = next;
42
+ }
43
+ export function getConnection() {
44
+ return connection;
45
+ }
46
+ export function clearConnection() {
47
+ connection = null;
48
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * QQ NapCat CLI Onboarding Adapter
3
+ *
4
+ * 提供 openclaw onboard 命令的交互式配置支持
5
+ */
6
+ import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
7
+ /**
8
+ * QQ NapCat Onboarding Adapter
9
+ */
10
+ export declare const qqOnboardingAdapter: ChannelOnboardingAdapter;
@@ -0,0 +1,98 @@
1
+ import { CHANNEL_ID, resolveQQAccount } from "./core/config.js";
2
+ /**
3
+ * QQ NapCat Onboarding Adapter
4
+ */
5
+ export const qqOnboardingAdapter = {
6
+ channel: CHANNEL_ID,
7
+ getStatus: async (ctx) => {
8
+ const { cfg } = ctx;
9
+ const config = cfg.channels?.[CHANNEL_ID];
10
+ const configured = Boolean(config.wsUrl);
11
+ return {
12
+ channel: CHANNEL_ID,
13
+ configured,
14
+ statusLines: configured
15
+ ? ["QQ (NapCat): 已配置"]
16
+ : ["QQ (NapCat): 未配置"],
17
+ selectionHint: configured ? "已配置" : "未配置",
18
+ quickstartScore: configured ? 1 : 10,
19
+ };
20
+ },
21
+ configure: async (ctx) => {
22
+ const { cfg, prompter } = ctx;
23
+ let next = cfg;
24
+ const resolvedAccount = resolveQQAccount({ cfg });
25
+ const accountConfigured = Boolean(resolvedAccount.wsUrl);
26
+ // 显示帮助
27
+ if (!accountConfigured) {
28
+ await prompter.note([
29
+ "1) 确保已安装 NapCat: https://github.com/NapNeko/NapCatQQ",
30
+ "2) 在 NapCat 配置中启用 WebSocket (正向 WS)",
31
+ "3) 默认地址: ws://localhost:3001",
32
+ "4) 如需访问控制,可设置 accessToken",
33
+ "",
34
+ "NapCat 文档: https://napneko.github.io/",
35
+ ].join("\n"), "QQ NapCat 配置");
36
+ }
37
+ let wsUrl = null;
38
+ let accessToken = null;
39
+ // 检查是否已配置
40
+ if (accountConfigured) {
41
+ const keep = await prompter.confirm({
42
+ message: "QQ NapCat 已配置,是否保留当前配置?",
43
+ initialValue: true,
44
+ });
45
+ if (!keep) {
46
+ wsUrl = String(await prompter.text({
47
+ message: "请输入 NapCat WebSocket URL",
48
+ placeholder: "ws://localhost:3001",
49
+ initialValue: resolvedAccount.wsUrl,
50
+ validate: (value) => (value?.trim() ? undefined : "WebSocket URL 不能为空"),
51
+ })).trim();
52
+ accessToken = String(await prompter.text({
53
+ message: "请输入 Access Token (可选,直接回车跳过)",
54
+ placeholder: "留空表示不使用 token",
55
+ initialValue: resolvedAccount.accessToken || undefined,
56
+ })).trim();
57
+ }
58
+ }
59
+ else {
60
+ // 新配置
61
+ wsUrl = String(await prompter.text({
62
+ message: "请输入 NapCat WebSocket URL",
63
+ placeholder: "ws://localhost:3001",
64
+ validate: (value) => (value?.trim() ? undefined : "WebSocket URL 不能为空"),
65
+ })).trim();
66
+ accessToken = String(await prompter.text({
67
+ message: "请输入 Access Token (可选,直接回车跳过)",
68
+ placeholder: "留空表示不使用 token",
69
+ })).trim();
70
+ }
71
+ // 应用配置
72
+ if (wsUrl) {
73
+ next = {
74
+ ...next,
75
+ channels: {
76
+ ...next.channels,
77
+ qq: {
78
+ ...next.channels?.[CHANNEL_ID],
79
+ enabled: true,
80
+ wsUrl,
81
+ ...(accessToken ? { accessToken } : {}),
82
+ },
83
+ },
84
+ };
85
+ }
86
+ return { cfg: next };
87
+ },
88
+ disable: (cfg) => ({
89
+ ...cfg,
90
+ channels: {
91
+ ...cfg.channels,
92
+ qq: {
93
+ ...cfg.channels?.[CHANNEL_ID],
94
+ enabled: false,
95
+ }
96
+ },
97
+ }),
98
+ };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * NapCat WebSocket API Types
3
+ * Based on NapCat OneBot 11 implementation
4
+ */
5
+ export interface NapCatReq<T = unknown> {
6
+ action: NapCatAction;
7
+ params?: T;
8
+ echo?: string;
9
+ }
10
+ export interface NapCatResp<T = unknown> {
11
+ status: 'ok' | 'failed';
12
+ retcode: number;
13
+ msg: string;
14
+ data?: T;
15
+ echo?: string;
16
+ }
17
+ export type NapCatAction = 'send_msg' | 'get_msg' | 'get_status' | 'get_file' | 'set_input_status';
18
+ export interface NapCatEvent {
19
+ time: number;
20
+ self_id: number;
21
+ post_type: NapCatPostType;
22
+ }
23
+ export type NapCatPostType = 'message' | 'message_sent' | 'message_sent_type' | 'message_private_sent_type' | 'notice' | 'request' | 'meta_event';
24
+ export interface NapCatMetaEvent extends NapCatEvent {
25
+ post_type: 'meta_event';
26
+ meta_event_type: 'lifecycle' | 'heartbeat';
27
+ sub_type?: 'connect' | 'disconnect' | 'enable' | 'disable';
28
+ }
29
+ export type NapCatMessage = NapCatTextSegment | NapCatAtSegment | NapCatImageSegment | NapCatReplySegment | NapCatFaceSegment | NapCatRecordSegment | NapCatFileSegment | NapCatJsonSegment | NapCatUnknownSegment;
30
+ export interface NapCatTextSegment {
31
+ type: 'text';
32
+ data: {
33
+ text: string;
34
+ };
35
+ }
36
+ export interface NapCatAtSegment {
37
+ type: 'at';
38
+ data: {
39
+ qq: string;
40
+ name?: string;
41
+ };
42
+ }
43
+ export interface NapCatImageSegment {
44
+ type: 'image';
45
+ data: {
46
+ file: string;
47
+ url?: string;
48
+ type?: string;
49
+ summary?: string;
50
+ };
51
+ }
52
+ export interface NapCatReplySegment {
53
+ type: 'reply';
54
+ data: {
55
+ id: string;
56
+ };
57
+ }
58
+ export interface NapCatFaceSegment {
59
+ type: 'face';
60
+ data: {
61
+ id: string;
62
+ };
63
+ }
64
+ export interface NapCatRecordSegment {
65
+ type: 'record';
66
+ data: {
67
+ file: string;
68
+ path?: string;
69
+ url?: string;
70
+ file_size?: string;
71
+ };
72
+ }
73
+ export interface NapCatFileSegment {
74
+ type: 'file';
75
+ data: {
76
+ file: string;
77
+ url?: string;
78
+ file_id?: string;
79
+ file_size?: string;
80
+ };
81
+ }
82
+ export interface NapCatJsonSegment {
83
+ type: 'json';
84
+ data: {
85
+ data: string;
86
+ };
87
+ }
88
+ export interface NapCatUnknownSegment {
89
+ type: string;
90
+ data: Record<string, unknown>;
91
+ }
92
+ export interface QQConfig {
93
+ wsUrl: string;
94
+ accessToken?: string;
95
+ enabled: boolean;
96
+ }
97
+ export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'failed';
98
+ export interface ConnectionStatus {
99
+ state: ConnectionState;
100
+ lastConnected?: number;
101
+ lastAttempted?: number;
102
+ error?: string;
103
+ reconnectAttempts?: number;
104
+ }
105
+ export interface OpenClawTextContent {
106
+ type: 'text';
107
+ text: string;
108
+ }
109
+ export interface OpenClawAtContent {
110
+ type: 'at';
111
+ userId: string;
112
+ isAll?: boolean;
113
+ }
114
+ export interface OpenClawImageContent {
115
+ type: 'image';
116
+ url: string;
117
+ /** Optional summary/description (e.g., "[动画表情]" for animated stickers) */
118
+ summary?: string;
119
+ }
120
+ export interface OpenClawReplyContent {
121
+ type: 'reply';
122
+ messageId: string;
123
+ }
124
+ export interface OpenClawAudioContent {
125
+ type: 'audio';
126
+ /** Local file path to the audio file */
127
+ path: string;
128
+ /** Optional URL for downloading the audio */
129
+ url?: string;
130
+ /** File name */
131
+ file: string;
132
+ /** File size in bytes */
133
+ fileSize?: number;
134
+ }
135
+ export interface OpenClawJsonContent {
136
+ type: 'json';
137
+ /** Raw JSON data string */
138
+ data: string;
139
+ /** Optional display text/prompt from the JSON */
140
+ prompt?: string;
141
+ }
142
+ export interface OpenClawFileContent {
143
+ type: 'file';
144
+ fileId: string;
145
+ fileSize?: number;
146
+ }
147
+ export type OpenClawMessage = OpenClawTextContent | OpenClawAtContent | OpenClawImageContent | OpenClawReplyContent | OpenClawAudioContent | OpenClawJsonContent | OpenClawFileContent;
148
+ export interface PendingRequest {
149
+ resolve: (response: NapCatResp) => void;
150
+ reject: (error: Error) => void;
151
+ timeout: NodeJS.Timeout;
152
+ }
153
+ /**
154
+ * Runtime logger interface from OpenClaw plugin-sdk
155
+ * (Not exported from openclaw/package, defined locally)
156
+ */
157
+ export interface RuntimeLogger {
158
+ debug?: (message: string) => void;
159
+ info: (message: string) => void;
160
+ warn: (message: string) => void;
161
+ error: (message: string) => void;
162
+ }
163
+ /**
164
+ * Standard outbound delivery result
165
+ */
166
+ export interface OutboundDeliveryResult {
167
+ /** Channel identifier */
168
+ channel: string;
169
+ /** Message ID returned by the channel */
170
+ messageId: string;
171
+ /** Error if delivery failed */
172
+ error?: Error;
173
+ /** Timestamp of delivery */
174
+ deliveredAt?: number;
175
+ /** Additional metadata */
176
+ metadata?: Record<string, unknown>;
177
+ }
178
+ /**
179
+ * Health status for connection
180
+ */
181
+ export interface HealthStatus {
182
+ healthy: boolean;
183
+ lastHeartbeatAt: number;
184
+ latencyMs?: number;
185
+ consecutiveFailures: number;
186
+ }
187
+ /**
188
+ * Sender information in get_msg response
189
+ */
190
+ export interface GetMsgSender {
191
+ user_id: number;
192
+ nickname: string;
193
+ card?: string;
194
+ }
195
+ export interface SendMsgReq {
196
+ message_type: 'private' | 'group';
197
+ user_id?: string;
198
+ group_id?: string;
199
+ message: string | NapCatMessage[];
200
+ }
201
+ export interface SendMsgResp {
202
+ message_id: number;
203
+ }
204
+ export interface GetMsgReq {
205
+ message_id: number;
206
+ }
207
+ export interface GetMsgResp {
208
+ self_id: number;
209
+ user_id: string;
210
+ time: number;
211
+ message_id: number;
212
+ message_seq: number;
213
+ real_id: number;
214
+ real_seq: string;
215
+ message_type: 'private' | 'group';
216
+ sender: GetMsgSender;
217
+ raw_message: string;
218
+ font: number;
219
+ sub_type?: string;
220
+ message: string | NapCatMessage[];
221
+ message_format: string;
222
+ post_type: string;
223
+ group_id?: number;
224
+ emoji_likes_list?: unknown[];
225
+ }
226
+ export interface GetFileReq {
227
+ file?: string;
228
+ file_id?: string;
229
+ }
230
+ export interface GetFileResp {
231
+ file?: string;
232
+ url?: string;
233
+ file_size?: string;
234
+ file_name?: string;
235
+ base64?: string;
236
+ }
237
+ export interface SetInputStatusReq {
238
+ user_id: string;
239
+ event_type: 0 | 1 | 2;
240
+ }
241
+ export interface DispatchMessageMedia {
242
+ type?: string;
243
+ path?: string;
244
+ url?: string;
245
+ }
246
+ export interface DispatchMessageReply {
247
+ id?: string;
248
+ content?: string;
249
+ sender?: string;
250
+ }
251
+ export interface DispatchMessageParams {
252
+ chatType: 'direct' | 'group';
253
+ chatId: string;
254
+ senderId: string;
255
+ senderName?: string;
256
+ messageId: string;
257
+ content: string;
258
+ media?: DispatchMessageMedia;
259
+ reply?: DispatchMessageReply;
260
+ timestamp: number;
261
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * NapCat WebSocket API Types
3
+ * Based on NapCat OneBot 11 implementation
4
+ */
5
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * CQCode 节点接口
3
+ */
4
+ export interface CQNode {
5
+ type: string;
6
+ data: Record<string, string>;
7
+ }
8
+ /**
9
+ * 生产级 CQCode 解析工具类
10
+ */
11
+ export declare class CQCodeUtils {
12
+ private static readonly UNESCAPE_MAP;
13
+ private static readonly UNESCAPE_REGEX;
14
+ /**
15
+ * 反转义字符串
16
+ * 使用单次正则替换,避免多次遍历和嵌套转义带来的逻辑错误
17
+ */
18
+ private static unescape;
19
+ /**
20
+ * 解析 CQ 码字符串为 JSON 对象数组
21
+ * @param text 原始消息字符串
22
+ */
23
+ static parse(text: string): CQNode[];
24
+ /**
25
+ * 辅助方法:从解析结果中提取所有纯文本内容(去除 CQ 码)
26
+ * 场景:用于生成通知摘要、日志记录等
27
+ */
28
+ static getTextOnly(nodes: CQNode[]): string;
29
+ /**
30
+ * 辅助方法:判断消息是否提及了某个 QQ
31
+ */
32
+ static isMentioned(nodes: CQNode[], qq: string | number): boolean;
33
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 生产级 CQCode 解析工具类
3
+ */
4
+ export class CQCodeUtils {
5
+ // 定义转义映射表
6
+ static UNESCAPE_MAP = {
7
+ '&#91;': '[',
8
+ '&#93;': ']',
9
+ '&#44;': ',',
10
+ '&amp;': '&',
11
+ };
12
+ // 定义转义正则(全局匹配)
13
+ static UNESCAPE_REGEX = /&#91;|&#93;|&#44;|&amp;/g;
14
+ /**
15
+ * 反转义字符串
16
+ * 使用单次正则替换,避免多次遍历和嵌套转义带来的逻辑错误
17
+ */
18
+ static unescape(str) {
19
+ if (!str)
20
+ return '';
21
+ return str.replace(this.UNESCAPE_REGEX, (match) => this.UNESCAPE_MAP[match] || match);
22
+ }
23
+ /**
24
+ * 解析 CQ 码字符串为 JSON 对象数组
25
+ * @param text 原始消息字符串
26
+ */
27
+ static parse(text) {
28
+ if (!text)
29
+ return [];
30
+ const nodes = [];
31
+ const regex = /\[CQ:([^\]]+)]/g;
32
+ let lastIndex = 0;
33
+ let match;
34
+ while ((match = regex.exec(text)) !== null) {
35
+ // 1. 提取 CQ 码前的纯文本
36
+ if (match.index > lastIndex) {
37
+ const textContent = text.substring(lastIndex, match.index);
38
+ nodes.push({
39
+ type: 'text',
40
+ data: { text: this.unescape(textContent) },
41
+ });
42
+ }
43
+ // 2. 解析 CQ 码主体
44
+ const content = match[1];
45
+ const parts = content.split(',');
46
+ const type = parts[0];
47
+ // 使用 Object.create(null) 防止原型污染,或者在赋值时做检查
48
+ const data = {};
49
+ for (let i = 1; i < parts.length; i++) {
50
+ const part = parts[i];
51
+ if (!part)
52
+ continue; // 跳过空段(处理类似 [CQ:at,,qq=1] 的情况)
53
+ const eqIndex = part.indexOf('=');
54
+ let key;
55
+ let value;
56
+ if (eqIndex !== -1) {
57
+ key = part.substring(0, eqIndex).trim(); // key 通常不含空格,安全起见 trim
58
+ value = this.unescape(part.substring(eqIndex + 1));
59
+ }
60
+ else {
61
+ key = part.trim();
62
+ value = '';
63
+ }
64
+ // 🛡️ 安全检查:防止原型污染
65
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
66
+ continue;
67
+ }
68
+ if (key) {
69
+ data[key] = value;
70
+ }
71
+ }
72
+ nodes.push({ type, data });
73
+ lastIndex = regex.lastIndex;
74
+ }
75
+ // 3. 提取剩余的纯文本
76
+ if (lastIndex < text.length) {
77
+ const textContent = text.substring(lastIndex);
78
+ nodes.push({
79
+ type: 'text',
80
+ data: { text: this.unescape(textContent) },
81
+ });
82
+ }
83
+ return nodes;
84
+ }
85
+ /**
86
+ * 辅助方法:从解析结果中提取所有纯文本内容(去除 CQ 码)
87
+ * 场景:用于生成通知摘要、日志记录等
88
+ */
89
+ static getTextOnly(nodes) {
90
+ return nodes
91
+ .filter(node => node.type === 'text')
92
+ .map(node => node.data.text)
93
+ .join('');
94
+ }
95
+ /**
96
+ * 辅助方法:判断消息是否提及了某个 QQ
97
+ */
98
+ static isMentioned(nodes, qq) {
99
+ const targetQQ = String(qq);
100
+ return nodes.some(node => node.type === 'at' && (node.data.qq === targetQQ || node.data.qq === 'all'));
101
+ }
102
+ }