@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.
- package/README.md +12 -16
- package/dist/index.d.ts +5 -11
- package/dist/index.js +9 -18
- package/dist/src/adapters/message.d.ts +15 -4
- package/dist/src/adapters/message.js +179 -124
- package/dist/src/channel.d.ts +2 -7
- package/dist/src/channel.js +231 -312
- package/dist/src/core/auth.d.ts +67 -0
- package/dist/src/core/auth.js +154 -0
- package/dist/src/core/config.d.ts +5 -7
- package/dist/src/core/config.js +6 -8
- package/dist/src/core/connection.d.ts +6 -5
- package/dist/src/core/connection.js +17 -70
- package/dist/src/core/dispatch.d.ts +7 -54
- package/dist/src/core/dispatch.js +210 -398
- package/dist/src/core/event-handler.d.ts +42 -0
- package/dist/src/core/event-handler.js +171 -0
- package/dist/src/core/request.d.ts +3 -8
- package/dist/src/core/request.js +13 -126
- package/dist/src/core/runtime.d.ts +2 -11
- package/dist/src/core/runtime.js +0 -47
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +3 -0
- package/dist/src/setup-surface.d.ts +2 -0
- package/dist/src/setup-surface.js +59 -0
- package/dist/src/types/index.d.ts +69 -25
- package/dist/src/types/index.js +3 -4
- package/dist/src/utils/cqcode.d.ts +0 -9
- package/dist/src/utils/cqcode.js +0 -17
- package/dist/src/utils/index.d.ts +0 -17
- package/dist/src/utils/index.js +17 -154
- package/dist/src/utils/log.js +2 -2
- package/dist/src/utils/markdown.d.ts +5 -0
- package/dist/src/utils/markdown.js +57 -5
- package/openclaw.plugin.json +3 -2
- package/package.json +9 -11
- package/dist/src/onboarding.d.ts +0 -10
- package/dist/src/onboarding.js +0 -98
package/dist/src/channel.js
CHANGED
|
@@ -1,259 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { listQQAccountIds, resolveQQAccount, QQConfigSchema, CHANNEL_ID } from "./core/config.js";
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, createChatChannelPlugin, } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { createPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup";
|
|
3
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
|
4
|
+
import { createScopedChannelConfigAdapter, adaptScopedAccountAccessor, } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
5
|
+
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
|
6
|
+
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
|
7
|
+
import { createAccountStatusSink, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
8
|
+
import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers";
|
|
9
|
+
import { listQQAccountIds, QQ_CHANNEL, QQConfigSchema, resolveQQAccount } from "./core/config";
|
|
11
10
|
import { eventListener, sendMsg, getStatus, getLoginInfo, getFriendList, getGroupList } from "./core/request.js";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}),
|
|
52
|
-
},
|
|
53
|
-
configSchema: buildChannelConfigSchema(QQConfigSchema),
|
|
54
|
-
setup: {
|
|
55
|
-
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
56
|
-
applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
|
|
57
|
-
cfg: cfg,
|
|
58
|
-
channelKey: "qq",
|
|
59
|
-
accountId,
|
|
60
|
-
name,
|
|
61
|
-
}),
|
|
62
|
-
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
63
|
-
const namedConfig = applyAccountNameToChannelSection({
|
|
64
|
-
cfg,
|
|
65
|
-
channelKey: "qq",
|
|
66
|
-
accountId,
|
|
67
|
-
name: input.name,
|
|
68
|
-
});
|
|
69
|
-
const next = accountId !== DEFAULT_ACCOUNT_ID ? migrateBaseNameToDefaultAccount({
|
|
70
|
-
cfg: namedConfig,
|
|
71
|
-
channelKey: "qq",
|
|
72
|
-
}) : namedConfig;
|
|
73
|
-
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
74
|
-
return {
|
|
75
|
-
...next,
|
|
76
|
-
channels: {
|
|
77
|
-
...next.channels,
|
|
78
|
-
qq: {
|
|
79
|
-
...next.channels?.["qq"],
|
|
80
|
-
enabled: true,
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
return {
|
|
86
|
-
...next,
|
|
87
|
-
channels: {
|
|
88
|
-
...next.channels,
|
|
89
|
-
qq: {
|
|
90
|
-
...next.channels?.["qq"],
|
|
91
|
-
enabled: true,
|
|
92
|
-
accounts: {
|
|
93
|
-
...next.channels?.["qq"]?.accounts,
|
|
94
|
-
[accountId]: {
|
|
95
|
-
...next.channels?.["qq"]?.accounts?.[accountId],
|
|
96
|
-
enabled: true,
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
messaging: {
|
|
105
|
-
normalizeTarget: (target) => {
|
|
106
|
-
return target.replace(/^qq:/i, "");
|
|
107
|
-
},
|
|
108
|
-
targetResolver: {
|
|
109
|
-
looksLikeId: (id) => {
|
|
110
|
-
const normalized = id.replace(/^qq:/i, "");
|
|
111
|
-
// 支持 private:xxx, group:xxx 格式
|
|
112
|
-
if (normalized.startsWith("private:") || normalized.startsWith("group:"))
|
|
113
|
-
return true;
|
|
114
|
-
// 支持纯数字QQ号或群号
|
|
115
|
-
return /^\d+$/.test(normalized);
|
|
116
|
-
},
|
|
117
|
-
hint: "private:<qqId> or group:<groupId>",
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
outbound: {
|
|
121
|
-
deliveryMode: "direct",
|
|
122
|
-
sendText: outboundSend,
|
|
123
|
-
sendMedia: outboundSend,
|
|
124
|
-
},
|
|
125
|
-
status: {
|
|
126
|
-
defaultRuntime: {
|
|
127
|
-
accountId: DEFAULT_ACCOUNT_ID,
|
|
128
|
-
name: "QQ",
|
|
129
|
-
running: false,
|
|
130
|
-
connected: false,
|
|
131
|
-
reconnectAttempts: 0,
|
|
132
|
-
lastConnectedAt: null,
|
|
133
|
-
lastDisconnect: null,
|
|
134
|
-
lastStartAt: null,
|
|
135
|
-
lastStopAt: null,
|
|
136
|
-
lastError: null,
|
|
137
|
-
},
|
|
138
|
-
buildChannelSummary: ({ snapshot }) => ({
|
|
139
|
-
configured: snapshot.configured ?? false,
|
|
140
|
-
running: snapshot.running ?? false,
|
|
141
|
-
lastStartAt: snapshot.lastStartAt ?? null,
|
|
142
|
-
lastStopAt: snapshot.lastStopAt ?? null,
|
|
143
|
-
lastError: snapshot.lastError ?? null,
|
|
144
|
-
probe: snapshot.probe,
|
|
145
|
-
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
146
|
-
}),
|
|
147
|
-
probeAccount: async () => {
|
|
148
|
-
const status = await getStatus();
|
|
149
|
-
log.debug('gateway', `Probe status: ${status.status}`);
|
|
150
|
-
setContextStatus({
|
|
151
|
-
lastProbeAt: Date.now(),
|
|
152
|
-
});
|
|
153
|
-
return {
|
|
154
|
-
ok: status.status === "ok",
|
|
155
|
-
status: status.retcode,
|
|
156
|
-
error: status.status === "failed" ? status.msg : null,
|
|
157
|
-
};
|
|
158
|
-
},
|
|
159
|
-
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
|
160
|
-
return {
|
|
161
|
-
accountId: DEFAULT_ACCOUNT_ID,
|
|
162
|
-
name: "QQ",
|
|
163
|
-
enabled: account.enabled,
|
|
164
|
-
configured: Boolean(account.wsUrl?.trim()),
|
|
165
|
-
linked: Boolean(account.wsUrl?.trim()),
|
|
166
|
-
running: runtime?.running ?? false,
|
|
167
|
-
connected: runtime?.connected ?? false,
|
|
168
|
-
lastStartAt: runtime?.lastStartAt ?? null,
|
|
169
|
-
lastStopAt: runtime?.lastStopAt ?? null,
|
|
170
|
-
lastError: runtime?.lastError ?? null,
|
|
171
|
-
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
172
|
-
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
173
|
-
probe,
|
|
174
|
-
lastProbeAt: runtime?.lastProbeAt ?? null,
|
|
175
|
-
};
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
gateway: {
|
|
179
|
-
startAccount: async (ctx) => {
|
|
180
|
-
setContext(ctx);
|
|
181
|
-
const { account, abortSignal } = ctx;
|
|
182
|
-
log.info('gateway', `Starting gateway`);
|
|
183
|
-
setContextStatus({
|
|
184
|
-
running: true,
|
|
185
|
-
lastStartAt: Date.now(),
|
|
186
|
-
});
|
|
187
|
-
// 检查是否已存在连接
|
|
188
|
-
const existingConnection = getConnection();
|
|
189
|
-
if (existingConnection) {
|
|
190
|
-
log.debug('gateway', `A connection is already running`);
|
|
191
|
-
return waitUntilAbort(abortSignal);
|
|
192
|
-
}
|
|
193
|
-
try {
|
|
194
|
-
const connection = new ConnectionManager(account);
|
|
195
|
-
onEvent(connection);
|
|
196
|
-
await connection.start();
|
|
197
|
-
setConnection(connection);
|
|
198
|
-
await loadLoginInfo();
|
|
199
|
-
log.info('gateway', `Started gateway`);
|
|
200
|
-
return waitUntilAbort(abortSignal);
|
|
201
|
-
}
|
|
202
|
-
catch (error) {
|
|
203
|
-
log.error('gateway', `Failed to start gateway:`, error);
|
|
204
|
-
setContextStatus({
|
|
205
|
-
lastError: error instanceof Error ? error.message : 'Failed to start gateway',
|
|
206
|
-
});
|
|
207
|
-
throw error;
|
|
208
|
-
}
|
|
209
|
-
},
|
|
210
|
-
stopAccount: async (_ctx) => {
|
|
211
|
-
const connection = getConnection();
|
|
212
|
-
if (connection) {
|
|
213
|
-
await connection.stop();
|
|
214
|
-
clearConnection();
|
|
215
|
-
}
|
|
216
|
-
setContextStatus({
|
|
217
|
-
running: false,
|
|
218
|
-
lastStopAt: Date.now(),
|
|
219
|
-
});
|
|
220
|
-
clearContext();
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
directory: {
|
|
224
|
-
self: async () => {
|
|
225
|
-
const info = await getLoginInfo();
|
|
226
|
-
if (!info.data) {
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
log.debug('directory', `self: ${JSON.stringify(info.data)}`);
|
|
230
|
-
return {
|
|
231
|
-
kind: "user",
|
|
232
|
-
id: info.data.user_id.toString(),
|
|
233
|
-
name: info.data.nickname,
|
|
234
|
-
};
|
|
235
|
-
},
|
|
236
|
-
listPeers: getFriends,
|
|
237
|
-
listPeersLive: getFriends,
|
|
238
|
-
listGroups: getGroups,
|
|
239
|
-
listGroupsLive: getGroups,
|
|
240
|
-
},
|
|
241
|
-
heartbeat: {
|
|
242
|
-
checkReady: async ({ cfg }) => {
|
|
243
|
-
const account = resolveQQAccount({ cfg });
|
|
244
|
-
if (!account.wsUrl) {
|
|
245
|
-
return { ok: false, reason: "not-configured" };
|
|
246
|
-
}
|
|
247
|
-
const connection = getConnection();
|
|
248
|
-
if (!connection?.isConnected) {
|
|
249
|
-
return { ok: false, reason: "not-connected" };
|
|
250
|
-
}
|
|
251
|
-
return { ok: true, reason: "ok" };
|
|
252
|
-
},
|
|
253
|
-
},
|
|
254
|
-
};
|
|
11
|
+
import { buildMediaMessage, Logger as log, markdownToText } from "./utils";
|
|
12
|
+
import { clearConnection, getConnection, setConnection, setLoginInfo } from "./core/runtime";
|
|
13
|
+
import { outboundMessageAdapter } from "./adapters/message";
|
|
14
|
+
import { ConnectionManager } from "./core/connection";
|
|
15
|
+
import { qqSetupWizard } from "./setup-surface";
|
|
16
|
+
import { createInboundHandler } from "./core/dispatch";
|
|
17
|
+
import { getQQRuntime } from "./runtime";
|
|
18
|
+
const formatAllowFromEntry = (entry) => entry
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/^(qq):/i, "")
|
|
21
|
+
.toLowerCase();
|
|
22
|
+
async function getFriends() {
|
|
23
|
+
const friendList = await getFriendList();
|
|
24
|
+
log.debug('directory', `friendList: ${JSON.stringify(friendList.data)}`);
|
|
25
|
+
return (friendList.data || []).map((friend) => ({
|
|
26
|
+
kind: "user",
|
|
27
|
+
id: friend.user_id.toString(),
|
|
28
|
+
name: friend.nickname,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
async function getGroups() {
|
|
32
|
+
const groupList = await getGroupList();
|
|
33
|
+
log.debug('directory', `groupList: ${JSON.stringify(groupList.data)}`);
|
|
34
|
+
return (groupList.data || []).map((group) => ({
|
|
35
|
+
kind: "group",
|
|
36
|
+
id: group.group_id.toString(),
|
|
37
|
+
name: group.group_name,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
async function loadLoginInfo() {
|
|
41
|
+
// 获取登录信息
|
|
42
|
+
const info = await getLoginInfo();
|
|
43
|
+
if (info.data) {
|
|
44
|
+
setLoginInfo({
|
|
45
|
+
userId: info.data.user_id.toString(),
|
|
46
|
+
nickname: info.data.nickname,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
255
50
|
async function outboundSend(ctx) {
|
|
256
|
-
const { to, text, mediaUrl, accountId, replyToId } = ctx;
|
|
51
|
+
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
|
257
52
|
log.debug("outbound", `send called - accountId: ${accountId}, to: ${to}, mediaUrl: ${mediaUrl ?? "null"}, replyToId: ${replyToId ?? "none"}`);
|
|
258
53
|
// Parse target (format: private:xxx or group:xxx)
|
|
259
54
|
const parts = to.split(":");
|
|
@@ -261,18 +56,9 @@ async function outboundSend(ctx) {
|
|
|
261
56
|
const chatType = type === "group" ? "group" : "private";
|
|
262
57
|
const chatId = id || to;
|
|
263
58
|
const content = [];
|
|
264
|
-
const
|
|
265
|
-
if (!context) {
|
|
266
|
-
log.warn('dispatch', `No gateway context`);
|
|
267
|
-
return {
|
|
268
|
-
channel: CHANNEL_ID,
|
|
269
|
-
messageId: "",
|
|
270
|
-
error: new Error(`No gateway context`),
|
|
271
|
-
deliveredAt: Date.now(),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
59
|
+
const account = resolveQQAccount({ cfg, accountId });
|
|
274
60
|
if (text) {
|
|
275
|
-
content.push({ type: "text", text:
|
|
61
|
+
content.push({ type: "text", text: account.markdownFormat ? markdownToText(text) : text });
|
|
276
62
|
}
|
|
277
63
|
if (mediaUrl) {
|
|
278
64
|
content.push(buildMediaMessage(mediaUrl));
|
|
@@ -283,7 +69,7 @@ async function outboundSend(ctx) {
|
|
|
283
69
|
if (content.length === 0) {
|
|
284
70
|
log.warn("outbound", `send called with no content - accountId: ${accountId}, to: ${to}, mediaUrl: ${mediaUrl ?? "null"}, replyToId: ${replyToId ?? "none"}`);
|
|
285
71
|
return {
|
|
286
|
-
channel:
|
|
72
|
+
channel: QQ_CHANNEL,
|
|
287
73
|
messageId: "",
|
|
288
74
|
error: new Error(`No content to send`),
|
|
289
75
|
deliveredAt: Date.now(),
|
|
@@ -293,58 +79,44 @@ async function outboundSend(ctx) {
|
|
|
293
79
|
message_type: chatType,
|
|
294
80
|
user_id: chatType === "private" ? chatId : undefined,
|
|
295
81
|
group_id: chatType === "group" ? chatId : undefined,
|
|
296
|
-
message:
|
|
82
|
+
message: await outboundMessageAdapter(content, account),
|
|
297
83
|
});
|
|
298
84
|
if (response.status === "ok" && response.data) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
log.debug("outbound", `send successfully, messageId: ${data.message_id}`);
|
|
85
|
+
const { message_id } = response.data;
|
|
86
|
+
log.debug("outbound", `send successfully, messageId: ${message_id}`);
|
|
302
87
|
return {
|
|
303
|
-
channel:
|
|
304
|
-
messageId:
|
|
88
|
+
channel: QQ_CHANNEL,
|
|
89
|
+
messageId: message_id.toString(),
|
|
305
90
|
deliveredAt: Date.now(),
|
|
306
91
|
};
|
|
307
92
|
}
|
|
308
93
|
else {
|
|
309
94
|
log.warn("outbound", `send failed, status: ${response.status}, retcode: ${response.retcode}, msg: ${response.msg ?? "none"}`);
|
|
310
95
|
return {
|
|
311
|
-
channel:
|
|
96
|
+
channel: QQ_CHANNEL,
|
|
312
97
|
messageId: "",
|
|
313
98
|
error: new Error(response.msg || "Send failed"),
|
|
314
99
|
deliveredAt: Date.now(),
|
|
315
100
|
};
|
|
316
101
|
}
|
|
317
102
|
}
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}));
|
|
326
|
-
}
|
|
327
|
-
async function getGroups() {
|
|
328
|
-
const groupList = await getGroupList();
|
|
329
|
-
log.debug('directory', `groupList: ${JSON.stringify(groupList.data)}`);
|
|
330
|
-
return (groupList.data || []).map((group) => ({
|
|
331
|
-
kind: "group",
|
|
332
|
-
id: group.group_id.toString(),
|
|
333
|
-
name: group.group_name,
|
|
334
|
-
}));
|
|
335
|
-
}
|
|
336
|
-
function onEvent(connection) {
|
|
337
|
-
connection.on("event", (event) => eventListener(event));
|
|
103
|
+
function onEvent(cfg, account, connection, statusSink) {
|
|
104
|
+
const handleInbound = createInboundHandler({
|
|
105
|
+
cfg,
|
|
106
|
+
account,
|
|
107
|
+
runtime: getQQRuntime(),
|
|
108
|
+
});
|
|
109
|
+
connection.on("event", (event) => eventListener(account, event, handleInbound));
|
|
338
110
|
connection.on("state-changed", (status) => {
|
|
339
111
|
log.info('gateway', `Connection state: ${status.state}`);
|
|
340
112
|
if (status.state === "connected") {
|
|
341
|
-
|
|
113
|
+
statusSink({
|
|
342
114
|
connected: true,
|
|
343
115
|
lastConnectedAt: Date.now(),
|
|
344
116
|
});
|
|
345
117
|
}
|
|
346
118
|
else if (status.state === "disconnected" || status.state === "failed") {
|
|
347
|
-
|
|
119
|
+
statusSink({
|
|
348
120
|
connected: false,
|
|
349
121
|
lastError: status.error,
|
|
350
122
|
lastDisconnect: {
|
|
@@ -354,21 +126,168 @@ function onEvent(connection) {
|
|
|
354
126
|
});
|
|
355
127
|
}
|
|
356
128
|
});
|
|
357
|
-
connection.on("reconnecting", (info) => {
|
|
358
|
-
log.info('gateway', `Reconnecting: ${info.reason}, attempt ${info.totalAttempts}`);
|
|
359
|
-
setContextStatus({
|
|
360
|
-
lastError: `Reconnecting (${info.reason})`,
|
|
361
|
-
reconnectAttempts: info.totalAttempts,
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
async function loadLoginInfo() {
|
|
366
|
-
// 获取登录信息
|
|
367
|
-
const info = await getLoginInfo();
|
|
368
|
-
if (info.data) {
|
|
369
|
-
setLoginInfo({
|
|
370
|
-
userId: info.data.user_id.toString(),
|
|
371
|
-
nickname: info.data.nickname,
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
129
|
}
|
|
130
|
+
export const qqPlugin = createChatChannelPlugin({
|
|
131
|
+
base: {
|
|
132
|
+
id: QQ_CHANNEL,
|
|
133
|
+
meta: {
|
|
134
|
+
id: QQ_CHANNEL,
|
|
135
|
+
label: "QQ",
|
|
136
|
+
selectionLabel: "QQ",
|
|
137
|
+
detailLabel: "QQ",
|
|
138
|
+
docsPath: "extensions/qq",
|
|
139
|
+
docsLabel: "qq",
|
|
140
|
+
blurb: "Connect OpenClaw to QQ Chat",
|
|
141
|
+
systemImage: "message",
|
|
142
|
+
quickstartAllowFrom: true,
|
|
143
|
+
},
|
|
144
|
+
capabilities: {
|
|
145
|
+
chatTypes: ["direct", "group"],
|
|
146
|
+
reactions: true,
|
|
147
|
+
reply: true,
|
|
148
|
+
media: true,
|
|
149
|
+
blockStreaming: true,
|
|
150
|
+
},
|
|
151
|
+
reload: { configPrefixes: [`channels.${QQ_CHANNEL}`] },
|
|
152
|
+
config: {
|
|
153
|
+
...createScopedChannelConfigAdapter({
|
|
154
|
+
sectionKey: QQ_CHANNEL,
|
|
155
|
+
listAccountIds: listQQAccountIds,
|
|
156
|
+
resolveAccount: adaptScopedAccountAccessor(resolveQQAccount),
|
|
157
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
158
|
+
clearBaseFields: ["name"],
|
|
159
|
+
resolveAllowFrom: (account) => account.messageDirect.allowFrom,
|
|
160
|
+
formatAllowFrom: (allowFrom) => formatNormalizedAllowFromEntries({
|
|
161
|
+
allowFrom,
|
|
162
|
+
normalizeEntry: formatAllowFromEntry,
|
|
163
|
+
}),
|
|
164
|
+
}),
|
|
165
|
+
isConfigured: (account) => !!account.accessToken?.trim(),
|
|
166
|
+
},
|
|
167
|
+
configSchema: buildChannelConfigSchema(QQConfigSchema),
|
|
168
|
+
setup: createPatchedAccountSetupAdapter({
|
|
169
|
+
channelKey: QQ_CHANNEL,
|
|
170
|
+
validateInput: ({ input }) => {
|
|
171
|
+
if (input.useEnv) {
|
|
172
|
+
return "The use of environment variables is not supported at this time.";
|
|
173
|
+
}
|
|
174
|
+
if (!input.useEnv && !input.token) {
|
|
175
|
+
return "QQ Chat requires token";
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
},
|
|
179
|
+
buildPatch: (input) => input.token ? { botToken: input.token } : {},
|
|
180
|
+
}),
|
|
181
|
+
setupWizard: qqSetupWizard,
|
|
182
|
+
messaging: {
|
|
183
|
+
normalizeTarget: (target) => {
|
|
184
|
+
return target.replace(/^qq:/i, "");
|
|
185
|
+
},
|
|
186
|
+
targetResolver: {
|
|
187
|
+
looksLikeId: (id) => {
|
|
188
|
+
const normalized = id.replace(/^qq:/i, "");
|
|
189
|
+
// 支持 private:xxx, group:xxx 格式
|
|
190
|
+
if (normalized.startsWith("private:") || normalized.startsWith("group:"))
|
|
191
|
+
return true;
|
|
192
|
+
// 支持纯数字QQ号或群号
|
|
193
|
+
return /^\d+$/.test(normalized);
|
|
194
|
+
},
|
|
195
|
+
hint: "private:<qqId> or group:<groupId>",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
status: createComputedAccountStatusAdapter({
|
|
199
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
200
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
201
|
+
configured: snapshot.configured ?? false,
|
|
202
|
+
running: snapshot.running ?? false,
|
|
203
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
204
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
205
|
+
lastError: snapshot.lastError ?? null,
|
|
206
|
+
probe: snapshot.probe,
|
|
207
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
208
|
+
}),
|
|
209
|
+
probeAccount: async () => {
|
|
210
|
+
const status = await getStatus();
|
|
211
|
+
log.debug('gateway', `Probe status: ${status.status}`);
|
|
212
|
+
return {
|
|
213
|
+
ok: status.status === "ok",
|
|
214
|
+
status: status.retcode,
|
|
215
|
+
error: status.status === "failed" ? status.msg : null,
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
resolveAccountSnapshot: ({ account }) => ({
|
|
219
|
+
accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
220
|
+
name: "QQ",
|
|
221
|
+
enabled: account.enabled,
|
|
222
|
+
configured: Boolean(account.wsUrl?.trim()),
|
|
223
|
+
})
|
|
224
|
+
}),
|
|
225
|
+
gateway: {
|
|
226
|
+
startAccount: async (ctx) => {
|
|
227
|
+
const account = ctx.account;
|
|
228
|
+
const statusSink = createAccountStatusSink({
|
|
229
|
+
accountId: account.accountId,
|
|
230
|
+
setStatus: ctx.setStatus,
|
|
231
|
+
});
|
|
232
|
+
log.info('gateway', `Starting QQ Chat`);
|
|
233
|
+
statusSink({
|
|
234
|
+
running: true,
|
|
235
|
+
lastStartAt: Date.now(),
|
|
236
|
+
});
|
|
237
|
+
await runPassiveAccountLifecycle({
|
|
238
|
+
abortSignal: ctx.abortSignal,
|
|
239
|
+
start: async () => {
|
|
240
|
+
const connection = new ConnectionManager(account);
|
|
241
|
+
onEvent(ctx.cfg, account, connection, statusSink);
|
|
242
|
+
await connection.start();
|
|
243
|
+
setConnection(connection);
|
|
244
|
+
await loadLoginInfo();
|
|
245
|
+
log.info('gateway', `Started gateway`);
|
|
246
|
+
},
|
|
247
|
+
stop: async () => {
|
|
248
|
+
const connection = getConnection();
|
|
249
|
+
if (connection) {
|
|
250
|
+
await connection.stop();
|
|
251
|
+
clearConnection();
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
onStop: async () => {
|
|
255
|
+
statusSink({
|
|
256
|
+
running: false,
|
|
257
|
+
lastStopAt: Date.now(),
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
directory: createChannelDirectoryAdapter({
|
|
264
|
+
self: async () => {
|
|
265
|
+
const info = await getLoginInfo();
|
|
266
|
+
if (!info.data) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
log.debug('directory', `self: ${JSON.stringify(info.data)}`);
|
|
270
|
+
return {
|
|
271
|
+
kind: "user",
|
|
272
|
+
id: info.data.user_id.toString(),
|
|
273
|
+
name: info.data.nickname,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
listPeers: getFriends,
|
|
277
|
+
listPeersLive: getFriends,
|
|
278
|
+
listGroups: getGroups,
|
|
279
|
+
listGroupsLive: getGroups,
|
|
280
|
+
}),
|
|
281
|
+
security: {},
|
|
282
|
+
},
|
|
283
|
+
outbound: {
|
|
284
|
+
base: {
|
|
285
|
+
deliveryMode: "direct",
|
|
286
|
+
},
|
|
287
|
+
attachedResults: {
|
|
288
|
+
channel: QQ_CHANNEL,
|
|
289
|
+
sendText: outboundSend,
|
|
290
|
+
sendMedia: outboundSend,
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 频道授权模块
|
|
3
|
+
*
|
|
4
|
+
* 使用三明治模式 (Sandwich Pattern) 集成 OpenClaw 原生授权系统:
|
|
5
|
+
*
|
|
6
|
+
* 1. 预处理层 (QQ 特有): denyFrom, policy='deny'
|
|
7
|
+
* 2. SDK 层 (OpenClaw 原生): commands.allowFrom 检查
|
|
8
|
+
* 3. 后处理层 (QQ 特有): allowFrom 覆盖, policy='allowlist'
|
|
9
|
+
*
|
|
10
|
+
* 授权优先级链 (从高到低):
|
|
11
|
+
* 1. denyFrom - 绝对拒绝
|
|
12
|
+
* 2. policy='deny' - 频道级全局拒绝
|
|
13
|
+
* 3. allowFrom - 频道级白名单 (最高优先授权)
|
|
14
|
+
* 4. commands.allowFrom.qq - 全局 QQ 专属授权
|
|
15
|
+
* 5. commands.allowFrom["*"] - 全局通配授权
|
|
16
|
+
* 6. policy='allow' - 频道级全局允许
|
|
17
|
+
* 7. policy='allowlist' 未匹配 - 拒绝
|
|
18
|
+
* 8. 默认 - 拒绝
|
|
19
|
+
*/
|
|
20
|
+
import type { QQAccount, QQAllowConfig, QQGroupConfig } from "../types";
|
|
21
|
+
/**
|
|
22
|
+
* QQ 命令授权拒绝原因
|
|
23
|
+
*/
|
|
24
|
+
export type QQDenialReason = "denyFrom" | "policy_deny" | "not_in_allowlist" | "default_deny";
|
|
25
|
+
/**
|
|
26
|
+
* QQ 命令授权匹配来源
|
|
27
|
+
*/
|
|
28
|
+
export type QQMatchedBy = "channel_allowFrom" | "commands_qq" | "commands_wildcard" | "policy_allow";
|
|
29
|
+
/**
|
|
30
|
+
* QQ 命令授权结果
|
|
31
|
+
*/
|
|
32
|
+
export interface QQCommandAuthorization {
|
|
33
|
+
/** QQ 频道标识 */
|
|
34
|
+
providerId: "qq";
|
|
35
|
+
/** Owner 列表 */
|
|
36
|
+
ownerList: string[];
|
|
37
|
+
/** 发送者 ID */
|
|
38
|
+
senderId: string;
|
|
39
|
+
/** 发送者是否是 Owner */
|
|
40
|
+
senderIsOwner: boolean;
|
|
41
|
+
/** 是否授权 */
|
|
42
|
+
isAuthorizedSender: boolean;
|
|
43
|
+
/** 拒绝原因(如果被拒绝) */
|
|
44
|
+
denialReason?: QQDenialReason;
|
|
45
|
+
/** 授权匹配来源(如果被授权) */
|
|
46
|
+
matchedBy?: QQMatchedBy;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* resolveQQCommandAuthorization 参数
|
|
50
|
+
*/
|
|
51
|
+
export interface ResolveQQCommandAuthorizationParams {
|
|
52
|
+
/** 发送者 ID */
|
|
53
|
+
senderId: string;
|
|
54
|
+
/** QQ 频道配置 (群组或私聊) */
|
|
55
|
+
qqConfig: QQAllowConfig;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 解析 QQ 频道命令授权
|
|
59
|
+
*
|
|
60
|
+
* 使用三明治模式整合 QQ 特有配置与 OpenClaw 全局授权系统
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveQQCommandAuthorization(params: ResolveQQCommandAuthorizationParams): QQCommandAuthorization;
|
|
63
|
+
/**
|
|
64
|
+
* 根据 chatType 获取对应的 QQ 配置
|
|
65
|
+
* 统一返回 QQGroupConfig 类型,私聊时使用默认值填充群组特有字段
|
|
66
|
+
*/
|
|
67
|
+
export declare function getQQConfigByChatType(isGroup: boolean, groupId: string | undefined, config: QQAccount): QQGroupConfig;
|