@zhin.js/adapter-qq 1.0.83 → 1.1.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/CHANGELOG.md +853 -16
- package/README.md +136 -233
- package/adapters/qq.js +50 -0
- package/adapters/qq.ts +71 -0
- package/agent/PERMITS.md +25 -0
- package/{skills/qq/SKILL.md → agent/skills/qq.md} +1 -1
- package/agent/tools/add_role.ts +21 -0
- package/agent/tools/channel_info.ts +17 -0
- package/agent/tools/create_role.ts +20 -0
- package/agent/tools/list_channels.ts +18 -0
- package/agent/tools/list_guilds.ts +14 -0
- package/agent/tools/list_roles.ts +18 -0
- package/agent/tools/member_detail.ts +18 -0
- package/agent/tools/remove_role.ts +21 -0
- package/commands/endpoint/add/[[id]].js +3 -0
- package/commands/endpoint/add/[[id]].ts +3 -0
- package/commands/endpoint/cancel.js +12 -0
- package/commands/endpoint/cancel.ts +15 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +12 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +56 -0
- package/lib/endpoint.js +456 -0
- package/lib/index.d.ts +8 -18
- package/lib/index.js +8 -338
- package/lib/outbound.d.ts +17 -0
- package/lib/outbound.js +234 -0
- package/lib/platform-permit.d.ts +15 -0
- package/lib/platform-permit.js +41 -0
- package/lib/protocol.d.ts +111 -0
- package/lib/protocol.js +166 -0
- package/lib/qq-bind-api.d.ts +36 -0
- package/lib/qq-bind-api.js +89 -0
- package/lib/qq-bind-flow.d.ts +19 -0
- package/lib/qq-bind-flow.js +85 -0
- package/lib/qq-bind-persist.d.ts +12 -0
- package/lib/qq-bind-persist.js +22 -0
- package/lib/qq-bot-kind-prompt.d.ts +9 -0
- package/lib/qq-bot-kind-prompt.js +37 -0
- package/lib/qq-endpoint-commands.d.ts +45 -0
- package/lib/qq-endpoint-commands.js +180 -0
- package/lib/qq-endpoint-config.d.ts +17 -0
- package/lib/qq-endpoint-config.js +22 -0
- package/lib/qq-intents.d.ts +27 -0
- package/lib/qq-intents.js +51 -0
- package/lib/qq-runtime-state.d.ts +30 -0
- package/lib/qq-runtime-state.js +14 -0
- package/lib/side-event-dispatch.d.ts +10 -0
- package/lib/side-event-dispatch.js +114 -0
- package/lib/webhook.d.ts +18 -0
- package/lib/webhook.js +103 -0
- package/lib/ws.d.ts +55 -0
- package/lib/ws.js +234 -0
- package/middlewares/bot-kind-reply.js +46 -0
- package/middlewares/bot-kind-reply.ts +60 -0
- package/package.json +69 -28
- package/plugin.js +18 -0
- package/schema.json +142 -0
- package/src/client.ts +16 -0
- package/src/endpoint.ts +534 -0
- package/src/index.ts +66 -326
- package/src/outbound.ts +255 -0
- package/src/platform-permit.ts +56 -0
- package/src/protocol.ts +273 -0
- package/src/qq-bind-api.ts +145 -0
- package/src/qq-bind-flow.ts +127 -0
- package/src/qq-bind-persist.ts +44 -0
- package/src/qq-bot-kind-prompt.ts +47 -0
- package/src/qq-endpoint-commands.ts +251 -0
- package/src/qq-endpoint-config.ts +44 -0
- package/src/qq-intents.ts +77 -0
- package/src/qq-runtime-state.ts +51 -0
- package/src/side-event-dispatch.ts +140 -0
- package/src/webhook.ts +132 -0
- package/src/ws.ts +331 -0
- package/client/Dashboard.tsx +0 -230
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -17
- package/dist/index.js +0 -30
- package/lib/adapter.d.ts +0 -17
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -49
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -102
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -337
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -16
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -58
- package/src/bot.ts +0 -347
- package/src/types.ts +0 -24
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
|
|
3
|
+
* 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
|
|
4
|
+
* @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
|
|
5
|
+
* 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
|
|
6
|
+
* qqEndpointCommands 供 commands/endpoint/ 下的命令文件默认导出。
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createEndpointCommands,
|
|
10
|
+
endpointCommandForbidden,
|
|
11
|
+
extractEndpointCommandReply,
|
|
12
|
+
formatEndpointList,
|
|
13
|
+
isEndpointOperator,
|
|
14
|
+
removeEndpointById,
|
|
15
|
+
type ConfiguredEndpointEntry,
|
|
16
|
+
type EndpointCommandReply,
|
|
17
|
+
} from 'zhin.js/adapter';
|
|
18
|
+
import { defineCommand } from 'zhin.js/command';
|
|
19
|
+
import { QQ_BOT_KIND_PROMPT, qqCommandSessionKey } from './qq-bot-kind-prompt.js';
|
|
20
|
+
import { startQqBindFlow } from './qq-bind-flow.js';
|
|
21
|
+
import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
|
|
22
|
+
import { addQqEndpointToConfig, listQqEndpointEntries } from './qq-endpoint-config.js';
|
|
23
|
+
import { defaultQqEndpointIntentFields, type QqBotKind } from './qq-intents.js';
|
|
24
|
+
import {
|
|
25
|
+
qqRuntimeStateToken,
|
|
26
|
+
type QqPendingBotKind,
|
|
27
|
+
type QqRuntimeState,
|
|
28
|
+
} from './qq-runtime-state.js';
|
|
29
|
+
|
|
30
|
+
export type QqCommandReply = EndpointCommandReply;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
|
|
34
|
+
* 仅 master 可执行 add/cancel/remove;未配置则放行(首个扫码绑定者即为 owner,
|
|
35
|
+
* legacy applyBindOwnership 会把 operator 写为新端点的 master)。
|
|
36
|
+
*/
|
|
37
|
+
export function isQqEndpointOperator(config: unknown, input: unknown): boolean {
|
|
38
|
+
return isEndpointOperator(config, input);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const QQ_ENDPOINT_FORBIDDEN = endpointCommandForbidden('QQ');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
|
|
45
|
+
*/
|
|
46
|
+
export function extractQqCommandReply(input: unknown): QqCommandReply {
|
|
47
|
+
return extractEndpointCommandReply(input);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function busyFooter(state: QqRuntimeState): string | undefined {
|
|
51
|
+
if (state.bindFlow) return '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消';
|
|
52
|
+
if (state.pendingBotKind) {
|
|
53
|
+
return `⚠️ endpoint「${state.pendingBotKind.endpointId}」等待公域/私域选择,回复 public/private 或 qq.endpoint cancel`;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
|
|
59
|
+
export function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string {
|
|
60
|
+
return formatEndpointList(qqEndpointListSpec, {
|
|
61
|
+
running: state.endpoints.values(),
|
|
62
|
+
configured: listQqEndpointEntries(projectRoot),
|
|
63
|
+
footer: busyFooter(state),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const qqEndpointListSpec = {
|
|
68
|
+
adapterKey: 'qq',
|
|
69
|
+
adapterDisplayName: 'QQ',
|
|
70
|
+
describeEntry: (entry: ConfiguredEndpointEntry) => `appid: ${String(entry.appid)}`,
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
function writeEndpointWithBotKind(
|
|
74
|
+
pending: QqPendingBotKind,
|
|
75
|
+
botKind: QqBotKind,
|
|
76
|
+
projectRoot?: string,
|
|
77
|
+
): string {
|
|
78
|
+
const envKeys = persistQqCredentialsToEnv(
|
|
79
|
+
pending.endpointId,
|
|
80
|
+
pending.appId,
|
|
81
|
+
pending.appSecret,
|
|
82
|
+
projectRoot,
|
|
83
|
+
);
|
|
84
|
+
const intentFields = defaultQqEndpointIntentFields(botKind);
|
|
85
|
+
const filePath = addQqEndpointToConfig(
|
|
86
|
+
{
|
|
87
|
+
id: pending.endpointId,
|
|
88
|
+
appid: envKeys.appidRef,
|
|
89
|
+
secret: envKeys.secretRef,
|
|
90
|
+
botKind: intentFields.botKind,
|
|
91
|
+
intents: [...intentFields.intents],
|
|
92
|
+
},
|
|
93
|
+
projectRoot,
|
|
94
|
+
);
|
|
95
|
+
return (
|
|
96
|
+
`✅ 已按 botKind=${intentFields.botKind} 将 endpoint「${pending.endpointId}」写入 .env 与 ${filePath}。\n` +
|
|
97
|
+
`intents: ${intentFields.intents.join(', ')}。\n` +
|
|
98
|
+
'⚠️ 需重启 zhin 后新 endpoint 才会生效。'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 完成 pending 的公域/私域选择:一次性写 .env + yaml。
|
|
104
|
+
* @returns 成功文案;调用方负责清 pending。
|
|
105
|
+
*/
|
|
106
|
+
export function completeQqPendingBotKind(
|
|
107
|
+
pending: QqPendingBotKind,
|
|
108
|
+
botKind: QqBotKind,
|
|
109
|
+
projectRoot?: string,
|
|
110
|
+
): string {
|
|
111
|
+
return writeEndpointWithBotKind(pending, botKind, projectRoot);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `qq.endpoint add [id]`:启动扫码绑定流程。
|
|
116
|
+
* 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
|
|
117
|
+
* 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
|
|
118
|
+
* 扫码成功后凭据暂存内存并询问公域/私域;确认后一次性写 .env + yaml。
|
|
119
|
+
*/
|
|
120
|
+
export function runQqEndpointAdd(
|
|
121
|
+
state: QqRuntimeState,
|
|
122
|
+
id: string | undefined,
|
|
123
|
+
reply: QqCommandReply,
|
|
124
|
+
projectRoot?: string,
|
|
125
|
+
input?: unknown,
|
|
126
|
+
): Promise<string> {
|
|
127
|
+
if (state.bindFlow || state.pendingBotKind) {
|
|
128
|
+
return Promise.resolve(
|
|
129
|
+
'已有进行中的 QQ 机器人绑定或待确认的公域/私域选择,请先发送 qq.endpoint cancel 取消后再试',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const endpointId = id?.trim() || undefined;
|
|
133
|
+
const sessionKey = qqCommandSessionKey(input);
|
|
134
|
+
return new Promise<string>((resolve) => {
|
|
135
|
+
let firstReplied = false;
|
|
136
|
+
const settle = (text: string) => {
|
|
137
|
+
if (!firstReplied) {
|
|
138
|
+
firstReplied = true;
|
|
139
|
+
resolve(text);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Follow-ups outlive the inbound Message reply scope; durable reply
|
|
143
|
+
// (OutboundHost) is provided by createEndpointCommands.bindFlow. Never
|
|
144
|
+
// let a late notify crash the process.
|
|
145
|
+
void Promise.resolve(reply(text)).catch(() => undefined);
|
|
146
|
+
};
|
|
147
|
+
const stop = startQqBindFlow(
|
|
148
|
+
{
|
|
149
|
+
onQrDisplayed: (url) => {
|
|
150
|
+
// QQ 出站当前仅支持纯文本(富媒体未迁移),二维码只能发链接文本
|
|
151
|
+
settle(
|
|
152
|
+
`请用手机 QQ 打开以下链接完成扫码绑定(二维码图片出站暂未支持,故发送链接):\n${url}`,
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
onQrExpired: async () => {
|
|
156
|
+
try {
|
|
157
|
+
await reply('二维码已过期,正在刷新,请扫描新链接…');
|
|
158
|
+
} catch {
|
|
159
|
+
// same as settle follow-up: best-effort after command scope ends
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
onSuccess: async (credentials) => {
|
|
163
|
+
state.bindFlow = null;
|
|
164
|
+
try {
|
|
165
|
+
const [{ appId, appSecret }] = credentials;
|
|
166
|
+
const finalName = endpointId ?? appId;
|
|
167
|
+
|
|
168
|
+
// 无会话上下文(Host 等)时无法追问,直接按公域一次性写入
|
|
169
|
+
if (!sessionKey) {
|
|
170
|
+
settle(
|
|
171
|
+
writeEndpointWithBotKind(
|
|
172
|
+
{
|
|
173
|
+
endpointId: finalName,
|
|
174
|
+
appId,
|
|
175
|
+
appSecret,
|
|
176
|
+
sessionKey: '',
|
|
177
|
+
},
|
|
178
|
+
'public',
|
|
179
|
+
projectRoot,
|
|
180
|
+
) +
|
|
181
|
+
'\n(当前会话无法交互选择公/私域;私域请手动改配置。)',
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
state.pendingBotKind = {
|
|
187
|
+
endpointId: finalName,
|
|
188
|
+
appId,
|
|
189
|
+
appSecret,
|
|
190
|
+
sessionKey,
|
|
191
|
+
};
|
|
192
|
+
settle(
|
|
193
|
+
`✅ 扫码成功!endpoint「${finalName}」。\n` +
|
|
194
|
+
QQ_BOT_KIND_PROMPT,
|
|
195
|
+
);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
settle(`绑定成功但写入配置失败:${error instanceof Error ? error.message : String(error)}`);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
onFailure: (error) => {
|
|
201
|
+
state.bindFlow = null;
|
|
202
|
+
settle(`❌ QQ 绑定失败:${error.message}`);
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{ source: 'zhin' },
|
|
206
|
+
);
|
|
207
|
+
state.bindFlow = { id: endpointId, stop };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `qq.endpoint cancel`:中止进行中的绑定或待选 botKind */
|
|
212
|
+
export function runQqEndpointCancel(state: QqRuntimeState): string {
|
|
213
|
+
if (state.bindFlow) {
|
|
214
|
+
state.bindFlow.stop();
|
|
215
|
+
state.bindFlow = null;
|
|
216
|
+
return '已取消进行中的 QQ 绑定流程';
|
|
217
|
+
}
|
|
218
|
+
if (state.pendingBotKind) {
|
|
219
|
+
const name = state.pendingBotKind.endpointId;
|
|
220
|
+
state.pendingBotKind = null;
|
|
221
|
+
return `已取消 endpoint「${name}」的绑定(尚未写入任何文件)`;
|
|
222
|
+
}
|
|
223
|
+
return '当前没有进行中的 QQ 绑定流程';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `qq.endpoint remove <id>`:从 zhin.config.yml 移除对应 endpoints 项 */
|
|
227
|
+
export function runQqEndpointRemove(
|
|
228
|
+
_state: QqRuntimeState,
|
|
229
|
+
id: string,
|
|
230
|
+
projectRoot?: string,
|
|
231
|
+
): string {
|
|
232
|
+
return removeEndpointById(qqEndpointListSpec, id, projectRoot);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
|
|
237
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项;cancel 为 QQ 特化,单独定义。
|
|
238
|
+
*/
|
|
239
|
+
export const qqEndpointCommands = createEndpointCommands({
|
|
240
|
+
...qqEndpointListSpec,
|
|
241
|
+
fields: [
|
|
242
|
+
{ key: 'appid', required: true, env: true, description: 'QQ 机器人 appid' },
|
|
243
|
+
{ key: 'secret', required: true, env: true, description: 'QQ 机器人 secret' },
|
|
244
|
+
],
|
|
245
|
+
running: (use) => use(qqRuntimeStateToken).endpoints.values(),
|
|
246
|
+
listFooter: (use) => busyFooter(use(qqRuntimeStateToken)),
|
|
247
|
+
addDescription:
|
|
248
|
+
'手机 QQ 扫码绑定机器人:确认公域/私域后一次性写入 .env 与 zhin.config.yml(重启生效)',
|
|
249
|
+
bindFlow: ({ id, reply, input, use }) =>
|
|
250
|
+
runQqEndpointAdd(use(qqRuntimeStateToken), id, reply, undefined, input),
|
|
251
|
+
}, defineCommand);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zhin.config.yml 中 plugins.qq.endpoints 的读写(Plugin Runtime 版)。
|
|
3
|
+
* 实现已泛化到 @zhin.js/adapter 的 endpoint-commands 套件(Document 节点级保留注释);
|
|
4
|
+
* 本文件仅保留 QQ 专用签名以兼容既有调用与测试。
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
addEndpointToConfig,
|
|
8
|
+
findEndpointConfigFile,
|
|
9
|
+
listConfiguredEndpoints,
|
|
10
|
+
removeEndpointFromConfig,
|
|
11
|
+
} from 'zhin.js/adapter';
|
|
12
|
+
|
|
13
|
+
export interface QqEndpointConfigEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
appid: string;
|
|
16
|
+
secret: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
|
|
21
|
+
export function findQqConfigFile(projectRoot?: string): string {
|
|
22
|
+
return findEndpointConfigFile('qq', projectRoot);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 读取 plugins.qq.endpoints(plain JS);plugins/qq 缺失或形态不符时返回 [] */
|
|
26
|
+
export function listQqEndpointEntries(projectRoot?: string): QqEndpointConfigEntry[] {
|
|
27
|
+
return listConfiguredEndpoints('qq', projectRoot) as QqEndpointConfigEntry[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 追加 endpoint 到 plugins.qq.endpoints;id 已存在时报错 */
|
|
31
|
+
export function addQqEndpointToConfig(
|
|
32
|
+
entry: QqEndpointConfigEntry,
|
|
33
|
+
projectRoot?: string,
|
|
34
|
+
): string {
|
|
35
|
+
return addEndpointToConfig('qq', entry, projectRoot);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 按 id 移除 plugins.qq.endpoints 项;不存在返回 false */
|
|
39
|
+
export function removeQqEndpointFromConfig(
|
|
40
|
+
id: string,
|
|
41
|
+
projectRoot?: string,
|
|
42
|
+
): { removed: boolean; filePath: string } {
|
|
43
|
+
return removeEndpointFromConfig('qq', id, projectRoot);
|
|
44
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 官方机器人 intents 与公域/私域映射。
|
|
3
|
+
*
|
|
4
|
+
* 群聊无公/私域之分,两类都订阅 `GROUP_AND_C2C_EVENT`。
|
|
5
|
+
* 频道消息 intent 不同:
|
|
6
|
+
* - public(公域):`PUBLIC_GUILD_MESSAGES`(仅 @ 机器人)
|
|
7
|
+
* - private(私域):`GUILD_MESSAGES`(频道全量消息;公域订阅会 Identify 失败)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type QqBotKind = 'public' | 'private';
|
|
11
|
+
|
|
12
|
+
export type QqIntent =
|
|
13
|
+
| 'GUILDS'
|
|
14
|
+
| 'GUILD_MEMBERS'
|
|
15
|
+
| 'GUILD_MESSAGES'
|
|
16
|
+
| 'GUILD_MESSAGE_REACTIONS'
|
|
17
|
+
| 'DIRECT_MESSAGE'
|
|
18
|
+
| 'GROUP_AND_C2C_EVENT'
|
|
19
|
+
| 'INTERACTION'
|
|
20
|
+
| 'MESSAGE_AUDIT'
|
|
21
|
+
| 'FORUMS_EVENT'
|
|
22
|
+
| 'AUDIO_ACTION'
|
|
23
|
+
| 'PUBLIC_GUILD_MESSAGES'
|
|
24
|
+
| 'GROUP_MEMBER';
|
|
25
|
+
|
|
26
|
+
/** 扫码绑定 / 未声明 botKind 时的默认(公域更常见)。 */
|
|
27
|
+
export const DEFAULT_QQ_BOT_KIND: QqBotKind = 'public';
|
|
28
|
+
|
|
29
|
+
/** 公/私域共用:群/C2C、群成员变更、频道基础事件、频道私信。 */
|
|
30
|
+
const QQ_SHARED_INTENTS = Object.freeze([
|
|
31
|
+
'GROUP_AND_C2C_EVENT',
|
|
32
|
+
'GROUP_MEMBER',
|
|
33
|
+
'GUILDS',
|
|
34
|
+
'GUILD_MEMBERS',
|
|
35
|
+
'DIRECT_MESSAGE',
|
|
36
|
+
] as const satisfies readonly QqIntent[]);
|
|
37
|
+
|
|
38
|
+
export const QQ_INTENTS_BY_KIND: Readonly<Record<QqBotKind, readonly QqIntent[]>> = Object.freeze({
|
|
39
|
+
public: Object.freeze([
|
|
40
|
+
...QQ_SHARED_INTENTS,
|
|
41
|
+
'PUBLIC_GUILD_MESSAGES',
|
|
42
|
+
] as const),
|
|
43
|
+
private: Object.freeze([
|
|
44
|
+
...QQ_SHARED_INTENTS,
|
|
45
|
+
'GUILD_MESSAGES',
|
|
46
|
+
] as const),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** 解析 botKind:仅接受 `public` / `private`。 */
|
|
50
|
+
export function parseQqBotKind(value: unknown): QqBotKind | undefined {
|
|
51
|
+
if (value === 'public' || value === 'private') return value;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 解析最终 intents:显式 `intents` 优先;否则按 `botKind`(默认 public)展开。
|
|
57
|
+
*/
|
|
58
|
+
export function resolveQqIntents(input: {
|
|
59
|
+
readonly intents?: readonly string[] | undefined;
|
|
60
|
+
readonly botKind?: unknown;
|
|
61
|
+
}): readonly QqIntent[] {
|
|
62
|
+
if (Array.isArray(input.intents) && input.intents.length > 0) {
|
|
63
|
+
return Object.freeze(input.intents.map(String)) as readonly QqIntent[];
|
|
64
|
+
}
|
|
65
|
+
const kind = parseQqBotKind(input.botKind) ?? DEFAULT_QQ_BOT_KIND;
|
|
66
|
+
return QQ_INTENTS_BY_KIND[kind];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 添加 endpoint 时写入配置的默认字段(显式展开,便于用户改)。 */
|
|
70
|
+
export function defaultQqEndpointIntentFields(
|
|
71
|
+
botKind: QqBotKind = DEFAULT_QQ_BOT_KIND,
|
|
72
|
+
): { readonly botKind: QqBotKind; readonly intents: readonly QqIntent[] } {
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
botKind,
|
|
75
|
+
intents: QQ_INTENTS_BY_KIND[botKind],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表 +
|
|
3
|
+
* 扫码绑定 / 待选 botKind 流程。由 plugin.ts setup() provide,
|
|
4
|
+
* adapter create 与 commands / middleware 通过 use(qqRuntimeStateToken) 共享。
|
|
5
|
+
*/
|
|
6
|
+
import { createToken } from 'zhin.js';
|
|
7
|
+
|
|
8
|
+
export interface QqRunningEndpoint {
|
|
9
|
+
id: string;
|
|
10
|
+
mode: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface QqBindFlowHandle {
|
|
14
|
+
/** 绑定期望的 endpoint id(未指定时为 undefined,成功后取 appId) */
|
|
15
|
+
id?: string;
|
|
16
|
+
stop: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 扫码成功后暂存在内存:确认公域/私域后再一次性写 .env + yaml。
|
|
21
|
+
* (提前写 .env 会触发 HMR,冲掉 pending。)
|
|
22
|
+
*/
|
|
23
|
+
export interface QqPendingBotKind {
|
|
24
|
+
readonly endpointId: string;
|
|
25
|
+
readonly appId: string;
|
|
26
|
+
readonly appSecret: string;
|
|
27
|
+
/** 发起 add 的会话键;仅同会话回复可完成选择 */
|
|
28
|
+
readonly sessionKey: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QqRuntimeState {
|
|
32
|
+
/** 当前 generation 已成功创建的 endpoint(id → 描述) */
|
|
33
|
+
readonly endpoints: Map<string, QqRunningEndpoint>;
|
|
34
|
+
/** 全局单例:同一时间只允许一个扫码绑定流程 */
|
|
35
|
+
bindFlow: QqBindFlowHandle | null;
|
|
36
|
+
/** 扫码完成后等待 botKind 选择(凭据仅在内存) */
|
|
37
|
+
pendingBotKind: QqPendingBotKind | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createQqRuntimeState(): QqRuntimeState {
|
|
41
|
+
return {
|
|
42
|
+
endpoints: new Map(),
|
|
43
|
+
bindFlow: null,
|
|
44
|
+
pendingBotKind: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const qqRuntimeStateToken = createToken<QqRuntimeState>(
|
|
49
|
+
'zhin.qq.runtime-state',
|
|
50
|
+
'QQ adapter runtime state (running endpoints + bind flow singleton)',
|
|
51
|
+
);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { receiveOneBotLikeSideEvent, type OneBotLikeRawEvent } from '@zhin.js/core';
|
|
2
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
|
+
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
|
+
import type { ApproveJoinRequestOptions } from 'qq-official-bot';
|
|
5
|
+
|
|
6
|
+
export interface QqSideEventCaller {
|
|
7
|
+
approveGroupJoinRequest?(
|
|
8
|
+
groupId: string,
|
|
9
|
+
userId: string,
|
|
10
|
+
options: ApproveJoinRequestOptions,
|
|
11
|
+
): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function bindQqBotSideEvents(
|
|
15
|
+
bot: { on(event: string, listener: (...args: unknown[]) => void): void },
|
|
16
|
+
dispatch: (eventName: string, raw: unknown) => void,
|
|
17
|
+
): void {
|
|
18
|
+
// qq-official-bot emits every dotted prefix. Listening at `notice` once avoids
|
|
19
|
+
// dispatching e.g. notice.group.member.increase three times.
|
|
20
|
+
bot.on('notice', (raw) => dispatch(resolveQqSideEventName(raw), raw));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveQqSideEventName(raw: unknown): string {
|
|
24
|
+
if (!raw || typeof raw !== 'object') return 'notice';
|
|
25
|
+
const record = raw as Record<string, unknown>;
|
|
26
|
+
return ['notice', record.notice_type, record.sub_type]
|
|
27
|
+
.filter((part) => part != null && String(part) !== '')
|
|
28
|
+
.map(String)
|
|
29
|
+
.join('.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toOneBotLikeRaw(eventName: string, raw: unknown): OneBotLikeRawEvent | null {
|
|
33
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
34
|
+
const record = raw as Record<string, unknown>;
|
|
35
|
+
const asId = (value: unknown): string | number | undefined => {
|
|
36
|
+
if (typeof value === 'string' || typeof value === 'number') return value;
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
const asTime = (value: unknown): number => {
|
|
40
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
41
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
42
|
+
const date = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
43
|
+
return Number.isFinite(date) ? date : Date.now();
|
|
44
|
+
};
|
|
45
|
+
if (eventName === 'notice.group.join_request') {
|
|
46
|
+
return {
|
|
47
|
+
...record,
|
|
48
|
+
post_type: 'request',
|
|
49
|
+
request_type: 'group',
|
|
50
|
+
flag: record.join_request_id != null ? String(record.join_request_id) : undefined,
|
|
51
|
+
user_id: asId(record.user_id),
|
|
52
|
+
group_id: asId(record.group_id),
|
|
53
|
+
comment: formatJoinRequestComment(record.verify_info),
|
|
54
|
+
time: asTime(record.time ?? record.timestamp ?? record.apply_at),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (eventName.startsWith('notice.')) {
|
|
58
|
+
const memberChange = eventName.match(/^notice\.group(?:\.member)?\.(increase|decrease)$/);
|
|
59
|
+
const memberUserId = asId(record.user_id)
|
|
60
|
+
?? asId((record.bot as { self_id?: unknown } | undefined)?.self_id);
|
|
61
|
+
return {
|
|
62
|
+
...record,
|
|
63
|
+
post_type: 'notice',
|
|
64
|
+
notice_type: memberChange ? `group_${memberChange[1]}` : eventName.replace(/^notice\./, ''),
|
|
65
|
+
...(memberChange && memberUserId != null ? { user_id: memberUserId } : {}),
|
|
66
|
+
time: asTime(record.time ?? record.timestamp),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatJoinRequestComment(value: unknown): string | undefined {
|
|
73
|
+
if (typeof value === 'string') return value;
|
|
74
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
75
|
+
const verify = value as {
|
|
76
|
+
verify_message?: unknown;
|
|
77
|
+
review_qa_list?: Array<{ question?: unknown; answer?: unknown }>;
|
|
78
|
+
};
|
|
79
|
+
if (typeof verify.verify_message === 'string') return verify.verify_message;
|
|
80
|
+
if (!Array.isArray(verify.review_qa_list)) return undefined;
|
|
81
|
+
const lines = verify.review_qa_list.flatMap((item) => {
|
|
82
|
+
const question = typeof item?.question === 'string' ? item.question : '';
|
|
83
|
+
const answer = typeof item?.answer === 'string' ? item.answer : '';
|
|
84
|
+
return question || answer ? [`${question}${question && answer ? ': ' : ''}${answer}`] : [];
|
|
85
|
+
});
|
|
86
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function receiveQqSideEvent(
|
|
90
|
+
emit: EndpointEventEmitter,
|
|
91
|
+
endpointKey: string,
|
|
92
|
+
caller: QqSideEventCaller,
|
|
93
|
+
eventName: string,
|
|
94
|
+
raw: unknown,
|
|
95
|
+
logger: ReturnType<typeof getAdapterLogger>,
|
|
96
|
+
): void {
|
|
97
|
+
if (!emit) return;
|
|
98
|
+
const mapped = toOneBotLikeRaw(eventName, raw);
|
|
99
|
+
if (!mapped) return;
|
|
100
|
+
const isJoinRequest = eventName === 'notice.group.join_request';
|
|
101
|
+
const joinRaw = isJoinRequest && raw && typeof raw === 'object'
|
|
102
|
+
? raw as { group_id?: string | number; user_id?: string | number; join_request_id?: string | number }
|
|
103
|
+
: undefined;
|
|
104
|
+
void receiveOneBotLikeSideEvent(emit, {
|
|
105
|
+
adapter: 'qq',
|
|
106
|
+
endpointKey,
|
|
107
|
+
platform: 'qq',
|
|
108
|
+
raw: mapped,
|
|
109
|
+
...(isJoinRequest && caller.approveGroupJoinRequest && joinRaw ? {
|
|
110
|
+
approve: async (flag, remark) => {
|
|
111
|
+
await caller.approveGroupJoinRequest!(
|
|
112
|
+
String(joinRaw.group_id ?? ''),
|
|
113
|
+
String(joinRaw.user_id ?? ''),
|
|
114
|
+
{
|
|
115
|
+
op: 'approve',
|
|
116
|
+
join_request_id: String(joinRaw.join_request_id ?? flag),
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
reject: async (flag, reason) => {
|
|
121
|
+
await caller.approveGroupJoinRequest!(
|
|
122
|
+
String(joinRaw.group_id ?? ''),
|
|
123
|
+
String(joinRaw.user_id ?? ''),
|
|
124
|
+
{
|
|
125
|
+
op: 'decline',
|
|
126
|
+
join_request_id: String(joinRaw.join_request_id ?? flag),
|
|
127
|
+
...(reason ? { reject_reason: reason } : {}),
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
} : {}),
|
|
132
|
+
}).catch((err) => {
|
|
133
|
+
logger.warn(formatCompact({
|
|
134
|
+
op: 'qq_side_event_failed',
|
|
135
|
+
endpoint: endpointKey,
|
|
136
|
+
event: eventName,
|
|
137
|
+
error: err instanceof Error ? err.message : String(err),
|
|
138
|
+
}));
|
|
139
|
+
});
|
|
140
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ HTTP webhook / middleware inbound via httpHostToken POST.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
+
import { Bot, ReceiverMode, type Sendable } from 'qq-official-bot';
|
|
7
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
8
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
9
|
+
import type { ResolvedQqHttpConfig } from './protocol.js';
|
|
10
|
+
import type { QqBotTransport } from './ws.js';
|
|
11
|
+
|
|
12
|
+
const logger = getLogger('qq');
|
|
13
|
+
|
|
14
|
+
export type QqHttpBotTransport = QqBotTransport & {
|
|
15
|
+
middleware: (ctx: unknown, next: () => Promise<void>) => Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CreateQqHttpBot = (config: ResolvedQqHttpConfig) => QqHttpBotTransport;
|
|
19
|
+
|
|
20
|
+
export interface QqWebhookHandler {
|
|
21
|
+
readonly config: ResolvedQqHttpConfig;
|
|
22
|
+
getBot(): (QqHttpBotTransport & { middleware?: QqHttpBotTransport['middleware'] }) | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerQqWebhookRoutes(
|
|
26
|
+
http: HttpHost,
|
|
27
|
+
handler: QqWebhookHandler,
|
|
28
|
+
): HttpRouteRegistration[] {
|
|
29
|
+
const webhookPath = handler.config.webhookPath;
|
|
30
|
+
return [
|
|
31
|
+
http.route('POST', webhookPath, async (request, response) => {
|
|
32
|
+
await handleQqWebhookRequest(request, response, handler);
|
|
33
|
+
}, { summary: 'QQ webhook callback', tags: ['qq'] }),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handleQqWebhookRequest(
|
|
38
|
+
request: IncomingMessage,
|
|
39
|
+
response: ServerResponse,
|
|
40
|
+
handler: QqWebhookHandler,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const bot = handler.getBot();
|
|
43
|
+
if (!bot?.middleware) {
|
|
44
|
+
response.writeHead(503, { 'Content-Type': 'text/plain' });
|
|
45
|
+
response.end('QQ receiver not ready');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
// 不预消费请求流:库的验签必须基于原始字节(JSON.parse → JSON.stringify
|
|
50
|
+
// 会改变空白/转义/键序导致 401),body 留 undefined 让库自己读流;
|
|
51
|
+
// 预消费还会让非 JSON body 时库监听已结束的 end 事件而挂死。
|
|
52
|
+
await bot.middleware({
|
|
53
|
+
req: request,
|
|
54
|
+
res: response,
|
|
55
|
+
request: { body: undefined },
|
|
56
|
+
}, async () => undefined);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.error('QQ webhook error:', error);
|
|
59
|
+
if (!response.headersSent) {
|
|
60
|
+
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
61
|
+
response.end('Internal Server Error');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readRequestBodyText(request: IncomingMessage): Promise<string> {
|
|
67
|
+
const chunks: Buffer[] = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of request) {
|
|
70
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
71
|
+
size += buffer.length;
|
|
72
|
+
if (size > 1_048_576) {
|
|
73
|
+
request.destroy();
|
|
74
|
+
throw new Error('Request body exceeds 1MB');
|
|
75
|
+
}
|
|
76
|
+
chunks.push(buffer);
|
|
77
|
+
}
|
|
78
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function defaultCreateHttpBot(config: ResolvedQqHttpConfig): QqHttpBotTransport {
|
|
82
|
+
const bot = new Bot({
|
|
83
|
+
appid: config.appid,
|
|
84
|
+
secret: config.secret,
|
|
85
|
+
mode: ReceiverMode.MIDDLEWARE,
|
|
86
|
+
platform: 'koa',
|
|
87
|
+
sandbox: config.sandbox,
|
|
88
|
+
dataDir: path.join(process.cwd(), 'data', 'qq'),
|
|
89
|
+
// 关闭内置 log4js,与 WebSocket 模式一致走 zhin adapter logger
|
|
90
|
+
logLevel: 'off',
|
|
91
|
+
} as never) as Bot<ReceiverMode.MIDDLEWARE, 'koa'> & QqBotTransport;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
api: bot,
|
|
95
|
+
start: () => bot.start().then(() => undefined),
|
|
96
|
+
stop: () => bot.stop().then(() => undefined),
|
|
97
|
+
on: (event, listener) => {
|
|
98
|
+
bot.on(event as never, listener as never);
|
|
99
|
+
},
|
|
100
|
+
removeAllListeners: () => {
|
|
101
|
+
bot.removeAllListeners(undefined as never);
|
|
102
|
+
},
|
|
103
|
+
sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message as Sendable),
|
|
104
|
+
sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message as Sendable),
|
|
105
|
+
sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message as Sendable),
|
|
106
|
+
sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message as Sendable),
|
|
107
|
+
recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
|
|
108
|
+
recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
|
|
109
|
+
recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
|
|
110
|
+
recallDirectMessage: (guildId, messageId) => bot.recallDirectMessage(guildId, messageId),
|
|
111
|
+
getGuilds: () => bot.guildService.getList(),
|
|
112
|
+
getChannels: (guildId) => bot.channelService.getList(guildId),
|
|
113
|
+
getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
|
|
114
|
+
getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
|
|
115
|
+
getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
|
|
116
|
+
createGuildRole: (guildId, name, color) =>
|
|
117
|
+
bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
|
|
118
|
+
addMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
119
|
+
await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
removeMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
123
|
+
await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
approveGroupJoinRequest: (groupId, userId, options) =>
|
|
127
|
+
(bot as { approveGroupJoinRequest?: QqBotTransport['approveGroupJoinRequest'] })
|
|
128
|
+
.approveGroupJoinRequest?.(groupId, userId, options)
|
|
129
|
+
?? Promise.reject(new Error('approveGroupJoinRequest is not available')),
|
|
130
|
+
middleware: (ctx, next) => bot.middleware(ctx as never, next as never),
|
|
131
|
+
};
|
|
132
|
+
}
|