@zhin.js/adapter-qq 2.0.11 → 3.0.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 +10 -0
- package/README.md +117 -9
- package/lib/adapter.d.ts +4 -1
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +12 -1
- package/lib/adapter.js.map +1 -1
- package/lib/bot.d.ts +14 -1
- package/lib/bot.d.ts.map +1 -1
- package/lib/bot.js +145 -17
- package/lib/bot.js.map +1 -1
- package/lib/gateway-config.d.ts +14 -0
- package/lib/gateway-config.d.ts.map +1 -0
- package/lib/gateway-config.js +32 -0
- package/lib/gateway-config.js.map +1 -0
- package/lib/group-at-normalize.d.ts +8 -0
- package/lib/group-at-normalize.d.ts.map +1 -0
- package/lib/group-at-normalize.js +71 -0
- package/lib/group-at-normalize.js.map +1 -0
- package/lib/inbound-normalize.d.ts +11 -0
- package/lib/inbound-normalize.d.ts.map +1 -0
- package/lib/inbound-normalize.js +47 -0
- package/lib/inbound-normalize.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +37 -17
- package/lib/index.js.map +1 -1
- package/lib/outbound-markdown.d.ts +12 -0
- package/lib/outbound-markdown.d.ts.map +1 -0
- package/lib/outbound-markdown.js +78 -0
- package/lib/outbound-markdown.js.map +1 -0
- package/lib/outbound-media.d.ts +9 -0
- package/lib/outbound-media.d.ts.map +1 -0
- package/lib/outbound-media.js +50 -0
- package/lib/outbound-media.js.map +1 -0
- package/lib/sdk-version.d.ts +8 -0
- package/lib/sdk-version.d.ts.map +1 -0
- package/lib/sdk-version.js +19 -0
- package/lib/sdk-version.js.map +1 -0
- package/lib/types.d.ts +22 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/adapter.ts +18 -1
- package/src/bot.ts +176 -18
- package/src/gateway-config.ts +48 -0
- package/src/group-at-normalize.ts +78 -0
- package/src/inbound-normalize.ts +57 -0
- package/src/index.ts +39 -17
- package/src/outbound-markdown.ts +93 -0
- package/src/outbound-media.ts +57 -0
- package/src/sdk-version.ts +21 -0
- package/src/types.ts +22 -0
package/src/adapter.ts
CHANGED
|
@@ -5,12 +5,20 @@ import {
|
|
|
5
5
|
Adapter,
|
|
6
6
|
Plugin,
|
|
7
7
|
} from "zhin.js";
|
|
8
|
+
import type { Router } from "@zhin.js/host-router";
|
|
8
9
|
import { QQBot } from "./bot.js";
|
|
9
10
|
import type { QQBotConfig, ReceiverMode } from "./types.js";
|
|
10
11
|
|
|
11
12
|
export class QQAdapter extends Adapter<QQBot<ReceiverMode>> {
|
|
12
|
-
|
|
13
|
+
#router?: Router;
|
|
14
|
+
|
|
15
|
+
constructor(plugin: Plugin, router?: Router) {
|
|
13
16
|
super(plugin, "qq", []);
|
|
17
|
+
this.#router = router;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getRouter(): Router | undefined {
|
|
21
|
+
return this.#router;
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
createBot(config: QQBotConfig<ReceiverMode>): QQBot<ReceiverMode> {
|
|
@@ -52,6 +60,15 @@ export class QQAdapter extends Adapter<QQBot<ReceiverMode>> {
|
|
|
52
60
|
// ── 生命周期 ───────────────────────────────────────────────────────
|
|
53
61
|
|
|
54
62
|
async start(): Promise<void> {
|
|
63
|
+
if (!this.#router) {
|
|
64
|
+
this.#router = (this.plugin.inject as (key: string) => Router | undefined)("router");
|
|
65
|
+
}
|
|
66
|
+
(this.plugin.useContext as (key: string, fn: (router: Router) => void) => void)(
|
|
67
|
+
"router",
|
|
68
|
+
(router) => {
|
|
69
|
+
this.#router = router;
|
|
70
|
+
},
|
|
71
|
+
);
|
|
55
72
|
await super.start();
|
|
56
73
|
}
|
|
57
74
|
|
package/src/bot.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
GroupMessageEvent,
|
|
8
8
|
} from "qq-official-bot";
|
|
9
9
|
import path from "path";
|
|
10
|
+
import { formatCompact } from "@zhin.js/logger";
|
|
11
|
+
import { registerFetchRoute, type RouterContext } from "@zhin.js/host-router/router";
|
|
10
12
|
import {
|
|
11
13
|
Bot as ZhinBot,
|
|
12
14
|
Message,
|
|
@@ -14,9 +16,38 @@ import {
|
|
|
14
16
|
SendContent,
|
|
15
17
|
segment,
|
|
16
18
|
} from "zhin.js";
|
|
17
|
-
import type {
|
|
19
|
+
import type { MessageElement } from "zhin.js";
|
|
20
|
+
import { ReceiverMode, type QQBotConfig, type ApplicationPlatform } from "./types.js";
|
|
18
21
|
import type { QQAdapter } from "./adapter.js";
|
|
22
|
+
import { normalizeQqInboundWsPayload, type QqWsPacket } from "./inbound-normalize.js";
|
|
23
|
+
import { normalizeGroupAtPrefix } from "./group-at-normalize.js";
|
|
24
|
+
import { SDK_VERSION, SDK_VERSION_HEADER } from "./sdk-version.js";
|
|
25
|
+
import { applyCustomAuthEndpoints } from "./gateway-config.js";
|
|
26
|
+
import { normalizeOutboundMarkdown } from "./outbound-markdown.js";
|
|
27
|
+
import { normalizeOutboundMedia } from "./outbound-media.js";
|
|
19
28
|
|
|
29
|
+
/** 从 qq-official-bot SendResult / 审核回包中解析出站消息 ID */
|
|
30
|
+
export function resolveOutboundMessageId(result: unknown): string {
|
|
31
|
+
if (!result || typeof result !== "object") {
|
|
32
|
+
throw new Error("QQ 发送消息失败:响应为空");
|
|
33
|
+
}
|
|
34
|
+
const row = result as Record<string, unknown>;
|
|
35
|
+
const nested = row.data && typeof row.data === "object"
|
|
36
|
+
? row.data as Record<string, unknown>
|
|
37
|
+
: undefined;
|
|
38
|
+
const audit = (row.message_audit ?? nested?.message_audit) as Record<string, unknown> | undefined;
|
|
39
|
+
const id = row.id ?? row.message_id ?? audit?.audit_id;
|
|
40
|
+
if (id == null || id === "") {
|
|
41
|
+
const code = row.code;
|
|
42
|
+
const msg = row.message;
|
|
43
|
+
throw new Error(
|
|
44
|
+
code != null
|
|
45
|
+
? `QQ 发送消息失败(${String(code)})${msg ? `: ${String(msg)}` : ""}`
|
|
46
|
+
: "QQ 发送消息失败:响应缺少消息 ID",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return String(id);
|
|
50
|
+
}
|
|
20
51
|
|
|
21
52
|
export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = ApplicationPlatform>
|
|
22
53
|
extends Bot
|
|
@@ -37,21 +68,74 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
|
|
|
37
68
|
|
|
38
69
|
constructor(public adapter: QQAdapter, config: QQBotConfig<T, M>) {
|
|
39
70
|
if (!config.data_dir) config.data_dir = path.join(process.cwd(), "data");
|
|
71
|
+
if (config.mode === ReceiverMode.MIDDLEWARE) {
|
|
72
|
+
const mw = config as QQBotConfig<ReceiverMode.MIDDLEWARE, M> & {
|
|
73
|
+
platform?: ApplicationPlatform;
|
|
74
|
+
};
|
|
75
|
+
if (!mw.platform) {
|
|
76
|
+
mw.platform = (mw.application ?? "koa") as ApplicationPlatform;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
40
79
|
super(config);
|
|
41
80
|
this.$config = config;
|
|
81
|
+
this.attachSdkVersionHeader();
|
|
82
|
+
applyCustomAuthEndpoints(this, config, this.pluginLogger);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 出站 QQ API 请求附带 SDK 版本,便于平台侧日志排查 */
|
|
86
|
+
private attachSdkVersionHeader(): void {
|
|
87
|
+
this.request.interceptors.request.use((reqConfig) => {
|
|
88
|
+
reqConfig.headers[SDK_VERSION_HEADER] = SDK_VERSION;
|
|
89
|
+
return reqConfig;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 归一化 QQ API v2 群聊字段后再交给 qq-official-bot 解析 */
|
|
94
|
+
dispatchEvent(event: string, wsRes: QqWsPacket): void {
|
|
95
|
+
this.pluginLogger.debug({
|
|
96
|
+
op: 'inbound_ws',
|
|
97
|
+
event,
|
|
98
|
+
group: String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?'),
|
|
99
|
+
});
|
|
100
|
+
if (event === 'GROUP_AT_MESSAGE_CREATE' || event === 'GROUP_MESSAGE_CREATE') {
|
|
101
|
+
this.pluginLogger.info(
|
|
102
|
+
`qq inbound ws: ${event} group=${String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?')}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
normalizeQqInboundWsPayload(event, wsRes);
|
|
106
|
+
super.dispatchEvent(event, wsRes);
|
|
42
107
|
}
|
|
43
108
|
|
|
44
109
|
private handleQQMessage(msg: PrivateMessageEvent | GroupMessageEvent): void {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
110
|
+
try {
|
|
111
|
+
const message = this.$formatMessage(msg);
|
|
112
|
+
this.adapter.emit("message.receive", message);
|
|
113
|
+
this.pluginLogger.debug(
|
|
114
|
+
`${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
|
|
115
|
+
);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
this.pluginLogger.warn(
|
|
118
|
+
`qq format inbound failed (${msg.message_type}): ${err instanceof Error ? err.message : String(err)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private handleGroupNotice(event: string, payload: { group_id?: string; operator_id?: string }): void {
|
|
124
|
+
this.pluginLogger.info(
|
|
125
|
+
`qq notice ${event}: group=${payload.group_id ?? '?'} operator=${payload.operator_id ?? '?'}`,
|
|
126
|
+
);
|
|
48
127
|
}
|
|
49
128
|
|
|
50
129
|
async $connect(): Promise<void> {
|
|
51
130
|
this.on("message.group", this.handleQQMessage.bind(this));
|
|
52
131
|
this.on("message.guild", this.handleQQMessage.bind(this));
|
|
53
132
|
this.on("message.private", this.handleQQMessage.bind(this));
|
|
133
|
+
this.on("notice.group.increase", (e) => this.handleGroupNotice('group.add_robot', e));
|
|
134
|
+
this.on("notice.group.decrease", (e) => this.handleGroupNotice('group.del_robot', e));
|
|
135
|
+
this.on("notice.group.receive_open", (e) => this.handleGroupNotice('group.msg_receive_open', e));
|
|
136
|
+
this.on("notice.group.receive_close", (e) => this.handleGroupNotice('group.msg_receive_close', e));
|
|
54
137
|
await this.start();
|
|
138
|
+
this.mountWebhookReceiver();
|
|
55
139
|
this.$connected = true;
|
|
56
140
|
try {
|
|
57
141
|
const self = await this.getSelfInfo();
|
|
@@ -72,11 +156,81 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
|
|
|
72
156
|
this.$connected = false;
|
|
73
157
|
}
|
|
74
158
|
|
|
159
|
+
private resolveWebhookPath(): string {
|
|
160
|
+
const raw = this.$config.webhookPath ?? "/qq/webhook";
|
|
161
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Webhook 入站:middleware(挂 host-router)或独立 HTTP 端口(qq-official-bot 内置服务)。
|
|
166
|
+
*/
|
|
167
|
+
private mountWebhookReceiver(): void {
|
|
168
|
+
const mode = this.$config.mode;
|
|
169
|
+
if (mode === ReceiverMode.MIDDLEWARE) {
|
|
170
|
+
const router = this.adapter.getRouter();
|
|
171
|
+
if (!router) {
|
|
172
|
+
throw new Error("QQ mode=middleware 需要启用 @zhin.js/host-router 插件");
|
|
173
|
+
}
|
|
174
|
+
const webhookPath = this.resolveWebhookPath();
|
|
175
|
+
const mw = this.middleware as (
|
|
176
|
+
ctx: RouterContext,
|
|
177
|
+
next: () => Promise<void>,
|
|
178
|
+
) => Promise<unknown>;
|
|
179
|
+
router.post(webhookPath, async (ctx: RouterContext) => {
|
|
180
|
+
this.pluginLogger.debug(ctx.body);
|
|
181
|
+
await mw(ctx, async () => {});
|
|
182
|
+
});
|
|
183
|
+
this.pluginLogger.info(formatCompact({
|
|
184
|
+
op: "webhook",
|
|
185
|
+
mode: "middleware",
|
|
186
|
+
path: webhookPath,
|
|
187
|
+
url: `POST ${webhookPath}`,
|
|
188
|
+
note: "无 /api 前缀;Host API 才走 /api/*",
|
|
189
|
+
}));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (mode === ReceiverMode.WEBHOOK) {
|
|
193
|
+
const cfg = this.$config as QQBotConfig<ReceiverMode.WEBHOOK>;
|
|
194
|
+
this.pluginLogger.info(formatCompact({
|
|
195
|
+
op: "webhook",
|
|
196
|
+
mode: "standalone",
|
|
197
|
+
port: cfg.port,
|
|
198
|
+
path: cfg.path,
|
|
199
|
+
url: `http://127.0.0.1:${cfg.port}${cfg.path}`,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
75
204
|
$formatMessage(msg: PrivateMessageEvent | GroupMessageEvent) {
|
|
205
|
+
const raw = msg as PrivateMessageEvent & GroupMessageEvent & {
|
|
206
|
+
group_openid?: string;
|
|
207
|
+
author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
|
|
208
|
+
__zhin_group_at?: boolean;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (msg.message_type === "group") {
|
|
212
|
+
if (!raw.group_id && raw.group_openid) {
|
|
213
|
+
raw.group_id = raw.group_openid;
|
|
214
|
+
}
|
|
215
|
+
if (!msg.user_id && raw.author) {
|
|
216
|
+
const uid = raw.author.member_openid ?? raw.author.user_openid ?? raw.author.id;
|
|
217
|
+
if (uid) msg.user_id = String(uid);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
76
221
|
let target_id = msg.user_id;
|
|
77
222
|
if (msg.message_type === "guild") target_id = msg.channel_id!;
|
|
78
|
-
if (msg.message_type === "group") target_id = msg.group_id
|
|
223
|
+
if (msg.message_type === "group") target_id = raw.group_id ?? msg.group_id ?? "";
|
|
79
224
|
if (msg.sub_type === "direct") target_id = `direct:${msg.guild_id}`;
|
|
225
|
+
|
|
226
|
+
let content = msg.message;
|
|
227
|
+
if (msg.message_type === "group" && Array.isArray(content)) {
|
|
228
|
+
const botAtIds = [this.$platformUserId, this.$config.appid]
|
|
229
|
+
.filter((id): id is string => Boolean(id))
|
|
230
|
+
.map(String);
|
|
231
|
+
content = normalizeGroupAtPrefix(content, botAtIds, raw.__zhin_group_at === true);
|
|
232
|
+
}
|
|
233
|
+
|
|
80
234
|
const result = Message.from(msg, {
|
|
81
235
|
$id: msg.message_id?.toString(),
|
|
82
236
|
$adapter: "qq" as const,
|
|
@@ -89,7 +243,7 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
|
|
|
89
243
|
id: target_id,
|
|
90
244
|
type: msg.message_type === "guild" ? "channel" : msg.message_type,
|
|
91
245
|
},
|
|
92
|
-
$content:
|
|
246
|
+
$content: content,
|
|
93
247
|
$raw: msg.raw_message,
|
|
94
248
|
$timestamp: Date.now(),
|
|
95
249
|
$recall: async () => {
|
|
@@ -110,28 +264,32 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
|
|
|
110
264
|
}
|
|
111
265
|
|
|
112
266
|
async $sendMessage(options: SendOptions): Promise<string> {
|
|
267
|
+
const content = normalizeOutboundMarkdown(
|
|
268
|
+
normalizeOutboundMedia(options.content),
|
|
269
|
+
this.$config.outboundMarkdown,
|
|
270
|
+
);
|
|
113
271
|
switch (options.type) {
|
|
114
272
|
case "private": {
|
|
115
273
|
if (options.id.startsWith("direct:")) {
|
|
116
274
|
const id = options.id.replace("direct:", "");
|
|
117
|
-
const result = await this.sendDirectMessage(id,
|
|
118
|
-
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(
|
|
119
|
-
return `direct-${options.id}:${result
|
|
275
|
+
const result = await this.sendDirectMessage(id, content);
|
|
276
|
+
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
|
|
277
|
+
return `direct-${options.id}:${resolveOutboundMessageId(result)}`;
|
|
120
278
|
} else {
|
|
121
|
-
const result = await this.sendPrivateMessage(options.id,
|
|
122
|
-
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(
|
|
123
|
-
return `private-${options.id}:${result
|
|
279
|
+
const result = await this.sendPrivateMessage(options.id, content);
|
|
280
|
+
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
|
|
281
|
+
return `private-${options.id}:${resolveOutboundMessageId(result)}`;
|
|
124
282
|
}
|
|
125
283
|
}
|
|
126
284
|
case "group": {
|
|
127
|
-
const result = await this.sendGroupMessage(options.id,
|
|
128
|
-
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(
|
|
129
|
-
return `group-${options.id}:${result
|
|
285
|
+
const result = await this.sendGroupMessage(options.id, content);
|
|
286
|
+
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
|
|
287
|
+
return `group-${options.id}:${resolveOutboundMessageId(result)}`;
|
|
130
288
|
}
|
|
131
289
|
case "channel": {
|
|
132
|
-
const result = await this.sendGuildMessage(options.id,
|
|
133
|
-
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(
|
|
134
|
-
return `channel-${options.id}:${result
|
|
290
|
+
const result = await this.sendGuildMessage(options.id, content);
|
|
291
|
+
this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
|
|
292
|
+
return `channel-${options.id}:${resolveOutboundMessageId(result)}`;
|
|
135
293
|
}
|
|
136
294
|
default:
|
|
137
295
|
throw new Error(`unsupported channel type ${options.type}`);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Logger } from "@zhin.js/logger";
|
|
2
|
+
import { formatCompact } from "@zhin.js/logger";
|
|
3
|
+
import type { Bot } from "qq-official-bot";
|
|
4
|
+
import { ReceiverMode } from "qq-official-bot";
|
|
5
|
+
import type { QQBotConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** qq-official-bot 默认 token 接口 */
|
|
8
|
+
export const DEFAULT_ACCESS_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
9
|
+
/** qq-official-bot 默认 gateway 路径(相对 API baseURL) */
|
|
10
|
+
export const DEFAULT_GATEWAY_URL = "/gateway/bot";
|
|
11
|
+
|
|
12
|
+
type AuthManagerSession = {
|
|
13
|
+
authManager: {
|
|
14
|
+
config: {
|
|
15
|
+
accessTokenUrl?: string;
|
|
16
|
+
gatewayUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 将自定义 gateway / token 地址注入 SDK Auth。
|
|
23
|
+
* SDK 仅在 websocket 模式自动读取配置;middleware / webhook 需在此补丁。
|
|
24
|
+
*/
|
|
25
|
+
export function applyCustomAuthEndpoints(
|
|
26
|
+
bot: Bot,
|
|
27
|
+
config: Pick<QQBotConfig<ReceiverMode>, "mode" | "accessTokenUrl" | "gatewayUrl">,
|
|
28
|
+
logger: Logger,
|
|
29
|
+
): void {
|
|
30
|
+
const { accessTokenUrl, gatewayUrl, mode } = config;
|
|
31
|
+
if (!accessTokenUrl && !gatewayUrl) return;
|
|
32
|
+
|
|
33
|
+
if (mode !== ReceiverMode.WEBSOCKET) {
|
|
34
|
+
const session = bot.sessionManager as unknown as AuthManagerSession;
|
|
35
|
+
if (accessTokenUrl) session.authManager.config.accessTokenUrl = accessTokenUrl;
|
|
36
|
+
if (gatewayUrl) session.authManager.config.gatewayUrl = gatewayUrl;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
logger.info(formatCompact({
|
|
40
|
+
op: "qq_gateway",
|
|
41
|
+
mode,
|
|
42
|
+
accessTokenUrl: accessTokenUrl ?? DEFAULT_ACCESS_TOKEN_URL,
|
|
43
|
+
gatewayUrl: gatewayUrl ?? DEFAULT_GATEWAY_URL,
|
|
44
|
+
note: mode === ReceiverMode.WEBSOCKET
|
|
45
|
+
? "websocket 入站使用 gatewayUrl"
|
|
46
|
+
: "middleware/webhook 仅 accessTokenUrl 生效",
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MessageElement } from "zhin.js";
|
|
2
|
+
|
|
3
|
+
function segmentAtId(seg: MessageElement): string {
|
|
4
|
+
if (seg.type !== "at" && seg.type !== "mention") return "";
|
|
5
|
+
const data = seg.data as Record<string, unknown> | undefined;
|
|
6
|
+
const raw = data?.user_id ?? data?.qq ?? data?.id;
|
|
7
|
+
return raw == null ? "" : String(raw);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function textMentionsBot(text: string, botIds: string[]): boolean {
|
|
11
|
+
for (const id of botIds) {
|
|
12
|
+
const re = new RegExp(`@${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
13
|
+
if (re.test(text)) return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripInlineAtBot(text: string, botIds: string[]): string {
|
|
19
|
+
let result = text;
|
|
20
|
+
for (const id of botIds) {
|
|
21
|
+
if (!id) continue;
|
|
22
|
+
const re = new RegExp(`^@${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
23
|
+
result = result.replace(re, "");
|
|
24
|
+
}
|
|
25
|
+
return result.trimStart();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 统一 QQ 群 @ 机器人前缀:
|
|
30
|
+
* - 去掉正文前的 at 段与内联 @,使命令/文本从首段开始匹配
|
|
31
|
+
* - 若曾 @ 机器人,在末尾追加规范 at 段,供 AI @ 触发器识别
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeGroupAtPrefix(
|
|
34
|
+
content: MessageElement[],
|
|
35
|
+
botAtIds: string[],
|
|
36
|
+
forceAt: boolean,
|
|
37
|
+
): MessageElement[] {
|
|
38
|
+
const ids = [...new Set(botAtIds.map(String).filter(Boolean))];
|
|
39
|
+
if (!ids.length || !Array.isArray(content)) return content;
|
|
40
|
+
|
|
41
|
+
let mentioned = forceAt;
|
|
42
|
+
const body: MessageElement[] = [];
|
|
43
|
+
|
|
44
|
+
for (const seg of content) {
|
|
45
|
+
if (seg.type === "at" || seg.type === "mention") {
|
|
46
|
+
const uid = segmentAtId(seg);
|
|
47
|
+
if (uid && ids.includes(uid)) {
|
|
48
|
+
mentioned = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
body.push(seg);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (seg.type === "text" && seg.data && typeof seg.data === "object") {
|
|
55
|
+
const raw = String((seg.data as { text?: string }).text ?? "");
|
|
56
|
+
if (textMentionsBot(raw, ids)) mentioned = true;
|
|
57
|
+
const stripped = stripInlineAtBot(raw, ids);
|
|
58
|
+
if (stripped) {
|
|
59
|
+
body.push({ ...seg, data: { ...(seg.data as object), text: stripped } });
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
body.push(seg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
while (body.length > 0 && body[0]?.type === "text") {
|
|
67
|
+
const t = String((body[0].data as { text?: string })?.text ?? "").trim();
|
|
68
|
+
if (t) break;
|
|
69
|
+
body.shift();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (mentioned) {
|
|
73
|
+
const canonicalId = ids[0]!;
|
|
74
|
+
body.push({ type: "at", data: { qq: canonicalId, id: canonicalId } });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return body.length ? body : content;
|
|
78
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 官方 API v2 入站 WebSocket payload 归一化(group_openid → group_id 等)。
|
|
3
|
+
* qq-official-bot 解析层仍按旧字段名取值,未归一化时群 @ 可能解析失败并被 SDK 静默丢弃。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type QqWsPacket = {
|
|
7
|
+
d?: Record<string, unknown>;
|
|
8
|
+
id?: string;
|
|
9
|
+
t?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const GROUP_MESSAGE_EVENTS = new Set([
|
|
13
|
+
'GROUP_AT_MESSAGE_CREATE',
|
|
14
|
+
'GROUP_MESSAGE_CREATE',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function normalizeQqInboundWsPayload(event: string, packet: QqWsPacket): void {
|
|
18
|
+
const d = packet.d;
|
|
19
|
+
if (!d || typeof d !== 'object') return;
|
|
20
|
+
|
|
21
|
+
if (GROUP_MESSAGE_EVENTS.has(event)) {
|
|
22
|
+
if (!d.group_id && typeof d.group_openid === 'string') {
|
|
23
|
+
d.group_id = d.group_openid;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const author = d.author;
|
|
27
|
+
if (author && typeof author === 'object') {
|
|
28
|
+
const a = author as Record<string, unknown>;
|
|
29
|
+
if (!a.id) {
|
|
30
|
+
a.id = a.member_openid ?? a.user_openid ?? a.id;
|
|
31
|
+
}
|
|
32
|
+
if (!a.username && !a.user_name) {
|
|
33
|
+
a.username = a.member_openid ?? a.user_openid ?? 'unknown';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!Array.isArray(d.mentions) && typeof d.content === 'string') {
|
|
38
|
+
const mentions: Array<{ id: string }> = [];
|
|
39
|
+
const re = /<@!?(\d+)>/g;
|
|
40
|
+
let m: RegExpExecArray | null;
|
|
41
|
+
while ((m = re.exec(d.content))) {
|
|
42
|
+
mentions.push({ id: m[1]! });
|
|
43
|
+
}
|
|
44
|
+
if (mentions.length) d.mentions = mentions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(d.attachments)) {
|
|
48
|
+
for (const att of d.attachments) {
|
|
49
|
+
if (att && typeof att === 'object' && (att as Record<string, unknown>).url == null) {
|
|
50
|
+
(att as Record<string, unknown>).url = '';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
d.__zhin_group_at = event === 'GROUP_AT_MESSAGE_CREATE';
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { usePlugin, type Plugin, type IGroupManagement, createGroupManagementTools, type ToolFeature } from "zhin.js";
|
|
6
|
+
import type { Router } from "@zhin.js/host-router";
|
|
6
7
|
import { QQAdapter } from "./adapter.js";
|
|
7
8
|
import { PageManager } from "@zhin.js/host-api";
|
|
8
9
|
|
|
@@ -10,6 +11,7 @@ declare module "zhin.js" {
|
|
|
10
11
|
namespace Plugin {
|
|
11
12
|
interface Contexts {
|
|
12
13
|
web: PageManager;
|
|
14
|
+
router: Router;
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
}
|
|
@@ -27,17 +29,19 @@ export { QQAdapter } from "./adapter.js";
|
|
|
27
29
|
const plugin = usePlugin();
|
|
28
30
|
const { provide, useContext } = plugin;
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
useContext("router", (router: Router) => {
|
|
33
|
+
provide({
|
|
34
|
+
name: "qq",
|
|
35
|
+
description: "QQ Official Bot Adapter",
|
|
36
|
+
mounted: async (p: Plugin) => {
|
|
37
|
+
const adapter = new QQAdapter(p, router);
|
|
38
|
+
await adapter.start();
|
|
39
|
+
return adapter;
|
|
40
|
+
},
|
|
41
|
+
dispose: async (adapter: QQAdapter) => {
|
|
42
|
+
await adapter.stop();
|
|
43
|
+
},
|
|
44
|
+
});
|
|
41
45
|
});
|
|
42
46
|
|
|
43
47
|
useContext('tool', 'qq', (toolService: ToolFeature, qq: QQAdapter) => {
|
|
@@ -296,28 +300,46 @@ useContext("router", "qq", (router: any, qq: QQAdapter) => {
|
|
|
296
300
|
}
|
|
297
301
|
});
|
|
298
302
|
|
|
299
|
-
//
|
|
303
|
+
// 频道列表(QQ 官方 API 返回 guild_id/guild_name,归一化为控制台用的 id/name)
|
|
300
304
|
router.get("/api/qq/bots/:name/guilds", async (ctx: any) => {
|
|
301
305
|
try {
|
|
302
306
|
const bot = qq.bots.get(ctx.params.name);
|
|
303
307
|
if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
|
|
304
308
|
if (!bot.$connected) { ctx.status = 400; ctx.body = { success: false, error: "Bot 未连接" }; return; }
|
|
305
|
-
const
|
|
306
|
-
|
|
309
|
+
const raw = await bot.getGuilds();
|
|
310
|
+
const guilds = (raw || []).map((g: any) => ({
|
|
311
|
+
id: g.guild_id ?? g.id,
|
|
312
|
+
name: g.guild_name ?? g.name,
|
|
313
|
+
icon: g.icon,
|
|
314
|
+
description: g.description,
|
|
315
|
+
memberCount: g.member_count,
|
|
316
|
+
ownerId: g.owner_id,
|
|
317
|
+
owner: g.owner,
|
|
318
|
+
joinTime: g.join_time,
|
|
319
|
+
}));
|
|
320
|
+
ctx.body = { success: true, data: guilds };
|
|
307
321
|
} catch (e: any) {
|
|
308
322
|
ctx.status = 500;
|
|
309
323
|
ctx.body = { success: false, error: e?.message || "获取频道失败" };
|
|
310
324
|
}
|
|
311
325
|
});
|
|
312
326
|
|
|
313
|
-
//
|
|
327
|
+
// 子频道列表(QQ 官方 API 返回 channel_id/channel_name,归一化为 id/name)
|
|
314
328
|
router.get("/api/qq/bots/:name/guilds/:guildId/channels", async (ctx: any) => {
|
|
315
329
|
try {
|
|
316
330
|
const bot = qq.bots.get(ctx.params.name);
|
|
317
331
|
if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
|
|
318
332
|
if (!bot.$connected) { ctx.status = 400; ctx.body = { success: false, error: "Bot 未连接" }; return; }
|
|
319
|
-
const
|
|
320
|
-
|
|
333
|
+
const raw = await bot.getChannels(ctx.params.guildId);
|
|
334
|
+
const channels = (raw || []).map((ch: any) => ({
|
|
335
|
+
id: ch.channel_id ?? ch.id,
|
|
336
|
+
name: ch.channel_name ?? ch.name,
|
|
337
|
+
type: ch.type,
|
|
338
|
+
subType: ch.sub_type,
|
|
339
|
+
position: ch.position,
|
|
340
|
+
parentId: ch.parent_id,
|
|
341
|
+
}));
|
|
342
|
+
ctx.body = { success: true, data: channels };
|
|
321
343
|
} catch (e: any) {
|
|
322
344
|
ctx.status = 500;
|
|
323
345
|
ctx.body = { success: false, error: e?.message || "获取子频道失败" };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将 AI 出站纯文本段合并为 QQ Markdown 消息(msg_type=2)。
|
|
3
|
+
* 官方已开放全量 Markdown,避免 **bold** 等语法以纯文本展示。
|
|
4
|
+
*/
|
|
5
|
+
import { segment, type MessageElement, type MessageSegment, type SendContent } from "zhin.js";
|
|
6
|
+
|
|
7
|
+
function isMessageSegment(seg: MessageElement): seg is MessageSegment {
|
|
8
|
+
return typeof seg.type === "string";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type OutboundMarkdownMode = boolean | "auto";
|
|
12
|
+
|
|
13
|
+
/** 常见 AI Markdown 输出特征(auto 模式) */
|
|
14
|
+
const MARKDOWN_HINT =
|
|
15
|
+
/(?:\*\*|__|`|\[.+\]\(.+\)|^#{1,6}\s|^>\s|^\s*[-*+]\s|^```|\|.+\|)/m;
|
|
16
|
+
|
|
17
|
+
const BODY_RICH_TYPES = new Set([
|
|
18
|
+
"image",
|
|
19
|
+
"audio",
|
|
20
|
+
"video",
|
|
21
|
+
"file",
|
|
22
|
+
"markdown",
|
|
23
|
+
"ark",
|
|
24
|
+
"embed",
|
|
25
|
+
"keyboard",
|
|
26
|
+
"button",
|
|
27
|
+
"face",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function textSegment(text: string): MessageSegment {
|
|
31
|
+
return { type: "text", data: { text } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asSegments(content: SendContent): MessageSegment[] {
|
|
35
|
+
if (typeof content === "string") return [textSegment(content)];
|
|
36
|
+
if (!Array.isArray(content)) {
|
|
37
|
+
return isMessageSegment(content) ? [content] : [];
|
|
38
|
+
}
|
|
39
|
+
return content.flatMap((item) =>
|
|
40
|
+
typeof item === "string" ? [textSegment(item)] : isMessageSegment(item) ? [item] : [],
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function textFromSegment(seg: MessageSegment): string {
|
|
45
|
+
if (seg.type !== "text") return "";
|
|
46
|
+
return seg.data.text ?? seg.data.content ?? "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function shouldConvert(mode: OutboundMarkdownMode | undefined, bodyText: string): boolean {
|
|
50
|
+
if (mode === false) return false;
|
|
51
|
+
if (mode === true) return bodyText.trim().length > 0;
|
|
52
|
+
return MARKDOWN_HINT.test(bodyText);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 出站归一化:保留 leading `reply`,将连续 text 合并为单条 markdown 段。
|
|
57
|
+
* 含图片/文件等富媒体时原样返回,避免破坏分条发送。
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeOutboundMarkdown(
|
|
60
|
+
content: SendContent,
|
|
61
|
+
mode: OutboundMarkdownMode | undefined = "auto",
|
|
62
|
+
): SendContent {
|
|
63
|
+
const segments = asSegments(content);
|
|
64
|
+
if (segments.length === 0) return content;
|
|
65
|
+
|
|
66
|
+
const prefix: MessageSegment[] = [];
|
|
67
|
+
let i = 0;
|
|
68
|
+
while (i < segments.length && segments[i].type === "reply") {
|
|
69
|
+
prefix.push(segments[i]);
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const body = segments.slice(i);
|
|
74
|
+
if (body.length === 0) return content;
|
|
75
|
+
|
|
76
|
+
if (body.some((seg) => BODY_RICH_TYPES.has(seg.type))) {
|
|
77
|
+
return content;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (body.some((seg) => seg.type !== "text" && seg.type !== "at")) {
|
|
81
|
+
return content;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const merged = body
|
|
85
|
+
.map((seg) => (seg.type === "at"
|
|
86
|
+
? `<@${String(seg.data.user_id ?? "")}>`
|
|
87
|
+
: textFromSegment(seg)))
|
|
88
|
+
.join("");
|
|
89
|
+
|
|
90
|
+
if (!shouldConvert(mode, merged)) return content;
|
|
91
|
+
|
|
92
|
+
return [...prefix, segment("markdown", { content: merged })];
|
|
93
|
+
}
|