@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,312 @@
1
+ /**
2
+ * QQ NapCat Plugin for OpenClaw
3
+ * Main plugin entry point
4
+ */
5
+ import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
6
+ import { messageIdToString, Logger as log } from "./utils/index.js";
7
+ import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection } from "./core/runtime.js";
8
+ import { ConnectionManager } from "./core/connection.js";
9
+ import { openClawToNapCatMessage } from "./adapters/message.js";
10
+ import { listQQAccountIds, resolveQQAccount, QQConfigSchema, CHANNEL_ID } from "./core/config.js";
11
+ import { eventListener, sendMsg } from "./core/request.js";
12
+ import { qqOnboardingAdapter } from "./onboarding.js";
13
+ export const qqPlugin = {
14
+ id: CHANNEL_ID,
15
+ meta: {
16
+ id: CHANNEL_ID,
17
+ label: "QQ",
18
+ selectionLabel: "QQ",
19
+ docsPath: "/channels/qq",
20
+ blurb: "通过 NapCat WebSocket 连接 QQ 机器人",
21
+ },
22
+ capabilities: {
23
+ chatTypes: ["direct", "group"],
24
+ reactions: true,
25
+ reply: true,
26
+ media: true,
27
+ blockStreaming: true,
28
+ },
29
+ reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
30
+ onboarding: qqOnboardingAdapter,
31
+ config: {
32
+ listAccountIds: (cfg) => listQQAccountIds(cfg),
33
+ resolveAccount: (cfg) => resolveQQAccount({ cfg }),
34
+ isEnabled: (account) => Boolean(account?.enabled),
35
+ isConfigured: (account) => Boolean(account?.wsUrl),
36
+ },
37
+ configSchema: buildChannelConfigSchema(QQConfigSchema),
38
+ messaging: {
39
+ normalizeTarget: (target) => {
40
+ return target.replace(/^qq:/i, "");
41
+ },
42
+ targetResolver: {
43
+ looksLikeId: (id) => {
44
+ const normalized = id.replace(/^qq:/i, "");
45
+ // 支持 private:xxx, group:xxx 格式
46
+ if (normalized.startsWith("private:") || normalized.startsWith("group:"))
47
+ return true;
48
+ // 支持纯数字QQ号或群号
49
+ return /^\d+$/.test(normalized);
50
+ },
51
+ hint: "private:<qqId> or group:<groupId>",
52
+ },
53
+ },
54
+ outbound: {
55
+ deliveryMode: "direct",
56
+ sendText: async (ctx) => {
57
+ const { to, text, accountId, cfg, replyToId } = ctx;
58
+ if (!accountId) {
59
+ return {
60
+ channel: CHANNEL_ID,
61
+ messageId: "",
62
+ error: new Error("accountId is required"),
63
+ deliveredAt: Date.now(),
64
+ };
65
+ }
66
+ const account = resolveQQAccount({ cfg });
67
+ if (!account) {
68
+ return {
69
+ channel: CHANNEL_ID,
70
+ messageId: "",
71
+ error: new Error(`Account not found: ${accountId}`),
72
+ deliveredAt: Date.now(),
73
+ };
74
+ }
75
+ const connection = getConnection();
76
+ if (!connection?.isConnected()) {
77
+ return {
78
+ channel: CHANNEL_ID,
79
+ messageId: "",
80
+ error: new Error(`Not connected for account: ${accountId}`),
81
+ deliveredAt: Date.now(),
82
+ };
83
+ }
84
+ // Parse target (format: private:xxx or group:xxx)
85
+ const parts = to.split(":");
86
+ const type = parts[0];
87
+ const id = parts[1];
88
+ const chatType = type === "group" ? "group" : "direct";
89
+ const chatId = id || to;
90
+ try {
91
+ const messageSegments = openClawToNapCatMessage([{ type: "text", text }], replyToId ?? undefined);
92
+ const response = await sendMsg({
93
+ message_type: chatType === "direct" ? "private" : "group",
94
+ user_id: chatType === "direct" ? chatId : undefined,
95
+ group_id: chatType === "group" ? chatId : undefined,
96
+ message: messageSegments,
97
+ });
98
+ // Update lastOutboundAt timestamp on successful send
99
+ if (response.status === "ok") {
100
+ setContextStatus({
101
+ lastOutboundAt: Date.now(),
102
+ });
103
+ }
104
+ if (response.status === "ok" && response.data) {
105
+ const data = response.data;
106
+ return {
107
+ channel: CHANNEL_ID,
108
+ messageId: messageIdToString(data.message_id),
109
+ deliveredAt: Date.now(),
110
+ };
111
+ }
112
+ else {
113
+ return {
114
+ channel: CHANNEL_ID,
115
+ messageId: "",
116
+ error: new Error(response.msg || "Send failed"),
117
+ deliveredAt: Date.now(),
118
+ };
119
+ }
120
+ }
121
+ catch (error) {
122
+ const errorMessage = error instanceof Error ? error.message : String(error);
123
+ return {
124
+ channel: CHANNEL_ID,
125
+ messageId: "",
126
+ error: new Error(errorMessage),
127
+ deliveredAt: Date.now(),
128
+ };
129
+ }
130
+ },
131
+ sendMedia: async (ctx) => {
132
+ const { to, mediaUrl, accountId, cfg, replyToId } = ctx;
133
+ log.debug("outbound", `sendMedia called - accountId: ${accountId}, to: ${to}, mediaUrl: ${mediaUrl ?? "null"}, replyToId: ${replyToId ?? "none"}`);
134
+ if (!accountId) {
135
+ log.warn("outbound", "sendMedia failed: accountId is required");
136
+ return {
137
+ channel: CHANNEL_ID,
138
+ messageId: "",
139
+ error: new Error("accountId is required"),
140
+ deliveredAt: Date.now(),
141
+ };
142
+ }
143
+ // Validate mediaUrl - check for null, undefined, empty string, or invalid URL
144
+ if (mediaUrl === null || mediaUrl === undefined || mediaUrl === "") {
145
+ log.warn("outbound", `sendMedia failed: mediaUrl is invalid (value: ${String(mediaUrl)})`);
146
+ return {
147
+ channel: CHANNEL_ID,
148
+ messageId: "",
149
+ error: new Error(`mediaUrl is required but received: ${mediaUrl === null ? "null" : mediaUrl === undefined ? "undefined" : "empty string"}`),
150
+ deliveredAt: Date.now(),
151
+ };
152
+ }
153
+ // Check if mediaUrl looks like a valid URL or file path
154
+ const trimmedUrl = String(mediaUrl).trim();
155
+ if (trimmedUrl === "" || trimmedUrl.length < 3) {
156
+ log.warn("outbound", `sendMedia failed: mediaUrl is too short or empty after trim`);
157
+ return {
158
+ channel: CHANNEL_ID,
159
+ messageId: "",
160
+ error: new Error(`mediaUrl appears to be invalid: "${trimmedUrl}"`),
161
+ deliveredAt: Date.now(),
162
+ };
163
+ }
164
+ const account = resolveQQAccount({ cfg });
165
+ if (!account) {
166
+ log.warn("outbound", `sendMedia failed: Account not found: ${accountId}`);
167
+ return {
168
+ channel: CHANNEL_ID,
169
+ messageId: "",
170
+ error: new Error(`Account not found: ${accountId}`),
171
+ deliveredAt: Date.now(),
172
+ };
173
+ }
174
+ const connection = getConnection();
175
+ if (!connection?.isConnected()) {
176
+ log.warn("outbound", `sendMedia failed: Not connected for account: ${accountId}`);
177
+ return {
178
+ channel: CHANNEL_ID,
179
+ messageId: "",
180
+ error: new Error(`Not connected for account: ${accountId}`),
181
+ deliveredAt: Date.now(),
182
+ };
183
+ }
184
+ // Parse target (format: private:xxx or group:xxx)
185
+ const parts = to.split(":");
186
+ const type = parts[0];
187
+ const id = parts[1];
188
+ const chatType = type === "group" ? "group" : "direct";
189
+ const chatId = id || to;
190
+ log.debug("outbound", `Sending media to ${chatType}:${chatId}, url: ${trimmedUrl.substring(0, 100)}${trimmedUrl.length > 100 ? "..." : ""}`);
191
+ try {
192
+ // Build media segment - NapCat requires 'file' field
193
+ // file can be: URL, file path, or base64
194
+ const mediaSegment = {
195
+ type: "image",
196
+ data: {
197
+ file: trimmedUrl,
198
+ url: trimmedUrl,
199
+ summary: "[图片]",
200
+ },
201
+ };
202
+ // Build message segments with optional reply
203
+ const messageSegments = [];
204
+ if (replyToId) {
205
+ messageSegments.push({ type: "reply", data: { id: replyToId } });
206
+ }
207
+ messageSegments.push(mediaSegment);
208
+ log.debug("outbound", `Message segments: ${JSON.stringify(messageSegments)}`);
209
+ const response = await sendMsg({
210
+ message_type: chatType === "direct" ? "private" : "group",
211
+ user_id: chatType === "direct" ? chatId : undefined,
212
+ group_id: chatType === "group" ? chatId : undefined,
213
+ message: messageSegments,
214
+ });
215
+ log.debug("outbound", `NapCat response - status: ${response.status}, retcode: ${response.retcode}, msg: ${response.msg ?? "none"}, data: ${JSON.stringify(response.data)}`);
216
+ // Update lastOutboundAt timestamp on successful send
217
+ if (response.status === "ok") {
218
+ setContextStatus({
219
+ lastOutboundAt: Date.now(),
220
+ });
221
+ }
222
+ if (response.status === "ok" && response.data) {
223
+ const data = response.data;
224
+ log.debug("outbound", `Media sent successfully, message_id: ${data.message_id}`);
225
+ return {
226
+ channel: CHANNEL_ID,
227
+ messageId: messageIdToString(data.message_id),
228
+ deliveredAt: Date.now(),
229
+ };
230
+ }
231
+ else {
232
+ const errorMsg = response.msg || "Send media failed";
233
+ log.warn("outbound", `sendMedia failed - status: ${response.status}, retcode: ${response.retcode}, msg: ${errorMsg}`);
234
+ return {
235
+ channel: CHANNEL_ID,
236
+ messageId: "",
237
+ error: new Error(`NapCat error [${response.retcode ?? "unknown"}]: ${errorMsg}`),
238
+ deliveredAt: Date.now(),
239
+ };
240
+ }
241
+ }
242
+ catch (error) {
243
+ const errorMessage = error instanceof Error ? error.message : String(error);
244
+ log.error("outbound", `sendMedia exception: ${errorMessage}`);
245
+ return {
246
+ channel: CHANNEL_ID,
247
+ messageId: "",
248
+ error: new Error(`sendMedia error: ${errorMessage}`),
249
+ deliveredAt: Date.now(),
250
+ };
251
+ }
252
+ },
253
+ },
254
+ status: {
255
+ buildAccountSnapshot: ({ account, runtime }) => {
256
+ return {
257
+ accountId: DEFAULT_ACCOUNT_ID,
258
+ name: CHANNEL_ID,
259
+ enabled: account.enabled,
260
+ configured: Boolean(account.wsUrl),
261
+ ...runtime,
262
+ };
263
+ },
264
+ },
265
+ gateway: {
266
+ startAccount: async (ctx) => {
267
+ setContext(ctx);
268
+ const { account } = ctx;
269
+ log.info('gateway', `Starting gateway`);
270
+ // Update start time
271
+ ctx.setStatus({
272
+ ...ctx.getStatus(),
273
+ running: true,
274
+ lastStartAt: Date.now(),
275
+ });
276
+ // Create new connection manager
277
+ const connection = new ConnectionManager(account);
278
+ connection.on("event", (event) => eventListener(event));
279
+ connection.on("state-changed", (status) => {
280
+ log.info('gateway', `State: ${status.state}`);
281
+ if (status.state === "connected") {
282
+ setContextStatus({
283
+ connected: true,
284
+ lastConnectedAt: Date.now(),
285
+ });
286
+ }
287
+ else if (status.state === "disconnected" || status.state === "failed") {
288
+ setContextStatus({
289
+ connected: false,
290
+ lastError: status.error,
291
+ });
292
+ }
293
+ });
294
+ await connection.start();
295
+ setConnection(connection);
296
+ log.info('gateway', `Started gateway`);
297
+ },
298
+ stopAccount: async (_ctx) => {
299
+ const connection = getConnection();
300
+ if (connection) {
301
+ await connection.stop();
302
+ clearConnection();
303
+ }
304
+ setContextStatus({
305
+ running: false,
306
+ connected: false,
307
+ lastStopAt: Date.now(),
308
+ });
309
+ clearContext();
310
+ },
311
+ },
312
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * QQ 配置管理
3
+ */
4
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
5
+ import type { QQConfig } from "../types/index.js";
6
+ import { z } from "zod";
7
+ export declare const CHANNEL_ID = "qq";
8
+ /**
9
+ * 列出所有 QQ 账户ID
10
+ */
11
+ export declare function listQQAccountIds(cfg: OpenClawConfig): string[];
12
+ /**
13
+ * 解析 QQ 账户配置
14
+ */
15
+ export declare function resolveQQAccount(params: {
16
+ cfg: OpenClawConfig;
17
+ }): QQConfig;
18
+ export declare const QQConfigSchema: z.ZodObject<{
19
+ wsUrl: z.ZodDefault<z.ZodString>;
20
+ accessToken: z.ZodDefault<z.ZodString>;
21
+ enable: z.ZodDefault<z.ZodBoolean>;
22
+ }, z.core.$strip>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * QQ 配置管理
3
+ */
4
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
5
+ import { z } from "zod";
6
+ export const CHANNEL_ID = "qq";
7
+ /**
8
+ * 列出所有 QQ 账户ID
9
+ */
10
+ export function listQQAccountIds(cfg) {
11
+ const config = cfg.channels?.[CHANNEL_ID];
12
+ if (config?.wsUrl) {
13
+ return [DEFAULT_ACCOUNT_ID];
14
+ }
15
+ return [];
16
+ }
17
+ /**
18
+ * 解析 QQ 账户配置
19
+ */
20
+ export function resolveQQAccount(params) {
21
+ const config = params.cfg.channels?.[CHANNEL_ID];
22
+ return {
23
+ enabled: config?.enabled !== false,
24
+ wsUrl: config?.wsUrl ?? "",
25
+ accessToken: config?.accessToken,
26
+ };
27
+ }
28
+ export const QQConfigSchema = z.object({
29
+ wsUrl: z.string().default("ws://127.0.0.1:3001"),
30
+ accessToken: z.string().default("access-token"),
31
+ enable: z.boolean().default(true)
32
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * WebSocket Connection Manager for NapCat
3
+ * Handles per-account WebSocket connections with auto-reconnect and heartbeat
4
+ */
5
+ import EventEmitter from 'events';
6
+ import type { NapCatResp, QQConfig, ConnectionStatus, NapCatAction } from '../types/index.js';
7
+ /**
8
+ * Connection Manager for a single NapCat account
9
+ */
10
+ export declare class ConnectionManager extends EventEmitter {
11
+ private config;
12
+ private ws;
13
+ private state;
14
+ private lastHeartbeatTime;
15
+ private totalReconnectAttempts;
16
+ private reconnectTimer?;
17
+ private reconnectAttempts;
18
+ private shouldReconnect;
19
+ private pendingRequests;
20
+ private healthStatus;
21
+ constructor(config: QQConfig);
22
+ /**
23
+ * Start the connection
24
+ */
25
+ start(): Promise<void>;
26
+ /**
27
+ * Stop the connection
28
+ */
29
+ stop(): Promise<void>;
30
+ /**
31
+ * Establish WebSocket connection
32
+ */
33
+ private connect;
34
+ /**
35
+ * Close WebSocket connection
36
+ */
37
+ private close;
38
+ private handleOpen;
39
+ private handleMessage;
40
+ /**
41
+ * Handle OneBot 11 meta_event (lifecycle and heartbeat)
42
+ */
43
+ private handleMetaEvent;
44
+ private handleError;
45
+ private handleClose;
46
+ private handleConnectionFailed;
47
+ private handleResponse;
48
+ private isNormalClosure;
49
+ private scheduleReconnect;
50
+ private clearReconnectTimer;
51
+ /**
52
+ * Send a request and wait for response
53
+ */
54
+ sendRequest<Req = unknown, Resp = unknown>(action: NapCatAction, params?: Req): Promise<NapCatResp<Resp>>;
55
+ private setState;
56
+ /**
57
+ * Get current connection status
58
+ */
59
+ getStatus(): ConnectionStatus;
60
+ /**
61
+ * Check if connected
62
+ */
63
+ isConnected(): boolean;
64
+ }
65
+ export declare function failResp<T>(msg?: string): Promise<NapCatResp<T>>;