@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
@@ -1,259 +1,54 @@
1
- /**
2
- * QQ NapCat Plugin for OpenClaw
3
- * Main plugin entry point
4
- */
5
- import { DEFAULT_ACCOUNT_ID, buildChannelConfigSchema, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, normalizeAccountId, waitUntilAbort } from "openclaw/plugin-sdk";
6
- import { messageIdToString, markdownToText, buildMediaMessage, Logger as log } from "./utils/index.js";
7
- import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection, setLoginInfo, getContext } 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";
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 { 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: "extensions/qq",
20
- blurb: "通过 NapCat WebSocket 连接 QQ 机器人",
21
- quickstartAllowFrom: true,
22
- },
23
- capabilities: {
24
- chatTypes: ["direct", "group"],
25
- reactions: true,
26
- reply: true,
27
- media: true,
28
- blockStreaming: true,
29
- },
30
- reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
31
- onboarding: qqOnboardingAdapter,
32
- config: {
33
- listAccountIds: (cfg) => listQQAccountIds(cfg),
34
- resolveAccount: (cfg) => resolveQQAccount({ cfg }),
35
- isConfigured: (account) => !!account.accessToken?.trim(),
36
- setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({
37
- cfg,
38
- sectionKey: "qq",
39
- accountId,
40
- enabled,
41
- allowTopLevel: true,
42
- }),
43
- deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({
44
- cfg,
45
- sectionKey: "qq",
46
- accountId,
47
- }),
48
- describeAccount: (account) => ({
49
- accountId: DEFAULT_ACCOUNT_ID,
50
- tokenSource: account.accessToken ? "config" : "none"
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 context = getContext();
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: context.account.markdownFormat ? markdownToText(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: CHANNEL_ID,
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: openClawToNapCatMessage(content),
82
+ message: await outboundMessageAdapter(content, account),
297
83
  });
298
84
  if (response.status === "ok" && response.data) {
299
- setContextStatus({ lastOutboundAt: Date.now() });
300
- const data = response.data;
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: CHANNEL_ID,
304
- messageId: messageIdToString(data.message_id),
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: CHANNEL_ID,
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
- async function getFriends() {
319
- const friendList = await getFriendList();
320
- log.debug('directory', `friendList: ${JSON.stringify(friendList.data)}`);
321
- return (friendList.data || []).map((friend) => ({
322
- kind: "user",
323
- id: friend.user_id.toString(),
324
- name: friend.nickname,
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
- setContextStatus({
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
- setContextStatus({
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;