@zhin.js/adapter-qq 5.0.1 → 5.0.3
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 +60 -0
- package/README.md +52 -408
- package/adapters/qq.ts +45 -0
- package/agent/tools/add_role.ts +23 -0
- package/agent/tools/channel_info.ts +19 -0
- package/agent/tools/create_role.ts +22 -0
- package/agent/tools/list_channels.ts +20 -0
- package/agent/tools/list_guilds.ts +17 -0
- package/agent/tools/list_roles.ts +20 -0
- package/agent/tools/member_detail.ts +20 -0
- package/agent/tools/remove_role.ts +23 -0
- package/lib/endpoint.d.ts +64 -120
- package/lib/endpoint.js +279 -564
- package/lib/index.d.ts +6 -23
- package/lib/index.js +6 -375
- package/lib/platform-permit.d.ts +1 -2
- package/lib/platform-permit.js +4 -2
- package/lib/protocol.d.ts +96 -0
- package/lib/protocol.js +129 -0
- package/lib/qq-agent-deps.d.ts +21 -0
- package/lib/qq-agent-deps.js +30 -0
- package/lib/webhook.d.ts +18 -0
- package/lib/webhook.js +100 -0
- package/lib/ws.d.ts +24 -0
- package/lib/ws.js +118 -0
- package/package.json +47 -33
- package/plugin.ts +13 -0
- package/schema.json +51 -0
- package/src/endpoint.ts +313 -605
- package/src/index.ts +55 -368
- package/src/platform-permit.ts +1 -2
- package/src/protocol.ts +229 -0
- package/src/qq-agent-deps.ts +60 -0
- package/src/webhook.ts +127 -0
- package/src/ws.ts +172 -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 -30
- package/dist/index.js +0 -30
- package/lib/adapter.d.ts +0 -32
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -82
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/gateway-config.d.ts +0 -14
- package/lib/gateway-config.d.ts.map +0 -1
- package/lib/gateway-config.js +0 -32
- package/lib/gateway-config.js.map +0 -1
- package/lib/group-at-normalize.d.ts +0 -8
- package/lib/group-at-normalize.d.ts.map +0 -1
- package/lib/group-at-normalize.js +0 -68
- package/lib/group-at-normalize.js.map +0 -1
- package/lib/inbound-action.d.ts +0 -15
- package/lib/inbound-action.d.ts.map +0 -1
- package/lib/inbound-action.js +0 -57
- package/lib/inbound-action.js.map +0 -1
- package/lib/inbound-normalize.d.ts +0 -11
- package/lib/inbound-normalize.d.ts.map +0 -1
- package/lib/inbound-normalize.js +0 -47
- package/lib/inbound-normalize.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/mention-enrich.d.ts +0 -12
- package/lib/mention-enrich.d.ts.map +0 -1
- package/lib/mention-enrich.js +0 -52
- package/lib/mention-enrich.js.map +0 -1
- package/lib/outbound-keyboard.d.ts +0 -4
- package/lib/outbound-keyboard.d.ts.map +0 -1
- package/lib/outbound-keyboard.js +0 -96
- package/lib/outbound-keyboard.js.map +0 -1
- package/lib/outbound-markdown.d.ts +0 -24
- package/lib/outbound-markdown.d.ts.map +0 -1
- package/lib/outbound-markdown.js +0 -177
- package/lib/outbound-markdown.js.map +0 -1
- package/lib/outbound-media.d.ts +0 -11
- package/lib/outbound-media.d.ts.map +0 -1
- package/lib/outbound-media.js +0 -51
- package/lib/outbound-media.js.map +0 -1
- package/lib/outbound-mixed-media.d.ts +0 -21
- package/lib/outbound-mixed-media.d.ts.map +0 -1
- package/lib/outbound-mixed-media.js +0 -78
- package/lib/outbound-mixed-media.js.map +0 -1
- package/lib/platform-permit.d.ts.map +0 -1
- package/lib/platform-permit.js.map +0 -1
- package/lib/qq-bind-api.d.ts +0 -37
- package/lib/qq-bind-api.d.ts.map +0 -1
- package/lib/qq-bind-api.js +0 -90
- package/lib/qq-bind-api.js.map +0 -1
- package/lib/qq-bind-flow.d.ts +0 -20
- package/lib/qq-bind-flow.d.ts.map +0 -1
- package/lib/qq-bind-flow.js +0 -86
- package/lib/qq-bind-flow.js.map +0 -1
- package/lib/qq-bind-persist.d.ts +0 -11
- package/lib/qq-bind-persist.d.ts.map +0 -1
- package/lib/qq-bind-persist.js +0 -52
- package/lib/qq-bind-persist.js.map +0 -1
- package/lib/qq-endpoint-manager.d.ts +0 -16
- package/lib/qq-endpoint-manager.d.ts.map +0 -1
- package/lib/qq-endpoint-manager.js +0 -159
- package/lib/qq-endpoint-manager.js.map +0 -1
- package/lib/sdk-version.d.ts +0 -8
- package/lib/sdk-version.d.ts.map +0 -1
- package/lib/sdk-version.js +0 -19
- package/lib/sdk-version.js.map +0 -1
- package/lib/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.js.map +0 -1
- package/lib/types.d.ts +0 -45
- 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 -101
- package/src/gateway-config.ts +0 -48
- package/src/group-at-normalize.ts +0 -76
- package/src/inbound-action.ts +0 -87
- package/src/inbound-normalize.ts +0 -57
- package/src/mention-enrich.ts +0 -71
- package/src/outbound-keyboard.ts +0 -118
- package/src/outbound-markdown.ts +0 -203
- package/src/outbound-media.ts +0 -58
- package/src/outbound-mixed-media.ts +0 -101
- package/src/qq-bind-api.ts +0 -145
- package/src/qq-bind-flow.ts +0 -127
- package/src/qq-bind-persist.ts +0 -71
- package/src/qq-endpoint-manager.ts +0 -199
- package/src/sdk-version.ts +0 -21
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -53
- /package/{skills/qq → agent}/PERMITS.md +0 -0
- /package/{skills/qq/SKILL.md → agent/skills/qq.md} +0 -0
package/src/outbound-markdown.ts
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 将 AI 出站纯文本段合并为 QQ Markdown 消息(msg_type=2)。
|
|
3
|
-
* 图文混排时合并为单条 Markdown(图片语法见 QQ 官方文档)。
|
|
4
|
-
*/
|
|
5
|
-
import { segment, type MessageElement, type MessageSegment, type SendContent } from "zhin.js";
|
|
6
|
-
import { resolveMediaFile } from "./outbound-media.js";
|
|
7
|
-
|
|
8
|
-
function isMessageSegment(seg: MessageElement): seg is MessageSegment {
|
|
9
|
-
return typeof seg.type === "string";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export type OutboundMarkdownMode = boolean | "auto";
|
|
13
|
-
|
|
14
|
-
/** 常见 AI Markdown 输出特征(auto 模式) */
|
|
15
|
-
const MARKDOWN_HINT =
|
|
16
|
-
/(?:\*\*|__|`|\[.+\]\(.+\)|^#{1,6}\s|^>\s|^\s*[-*+]\s|^```|\|.+\|)/m;
|
|
17
|
-
|
|
18
|
-
const BLOCKING_RICH_TYPES = new Set([
|
|
19
|
-
"audio",
|
|
20
|
-
"video",
|
|
21
|
-
"file",
|
|
22
|
-
"markdown",
|
|
23
|
-
"ark",
|
|
24
|
-
"embed",
|
|
25
|
-
"keyboard",
|
|
26
|
-
"button",
|
|
27
|
-
"face",
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
const TEXT_IMAGE_MIX_TYPES = new Set(["text", "at", "image"]);
|
|
31
|
-
|
|
32
|
-
function textSegment(text: string): MessageSegment {
|
|
33
|
-
return { type: "text", data: { text } };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function asOutboundSegments(content: SendContent): MessageSegment[] {
|
|
37
|
-
if (typeof content === "string") return [textSegment(content)];
|
|
38
|
-
if (!Array.isArray(content)) {
|
|
39
|
-
return isMessageSegment(content) ? [content] : [];
|
|
40
|
-
}
|
|
41
|
-
return content.flatMap((item) =>
|
|
42
|
-
typeof item === "string" ? [textSegment(item)] : isMessageSegment(item) ? [item] : [],
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function splitReplyPrefix(segments: MessageSegment[]): {
|
|
47
|
-
prefix: MessageSegment[];
|
|
48
|
-
body: MessageSegment[];
|
|
49
|
-
} {
|
|
50
|
-
const prefix: MessageSegment[] = [];
|
|
51
|
-
let i = 0;
|
|
52
|
-
while (i < segments.length && segments[i].type === "reply") {
|
|
53
|
-
prefix.push(segments[i]);
|
|
54
|
-
i++;
|
|
55
|
-
}
|
|
56
|
-
return { prefix, body: segments.slice(i) };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function textFromSegment(seg: MessageSegment): string {
|
|
60
|
-
if (seg.type !== "text") return "";
|
|
61
|
-
return seg.data.text ?? seg.data.content ?? "";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function isRemoteUrl(value: string): boolean {
|
|
65
|
-
return /^https?:\/\//i.test(value);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function isInlineMedia(value: string): boolean {
|
|
69
|
-
return value.startsWith("base64://")
|
|
70
|
-
|| /^data:[^/]+\/[^;]+;base64,/i.test(value)
|
|
71
|
-
|| value.startsWith("file://")
|
|
72
|
-
|| (value.startsWith("/") && !isRemoteUrl(value));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** QQ Markdown 图片语法: */
|
|
76
|
-
export function formatQqMarkdownImage(
|
|
77
|
-
url: string,
|
|
78
|
-
data: Record<string, unknown> = {},
|
|
79
|
-
): string {
|
|
80
|
-
const width = typeof data.width === "number" && data.width > 0 ? data.width : 200;
|
|
81
|
-
const height = typeof data.height === "number" && data.height > 0 ? data.height : width;
|
|
82
|
-
const label = typeof data.name === "string" && data.name.trim() ? data.name.trim() : "图片";
|
|
83
|
-
return ``;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** 将 image 段解析为 Markdown 可嵌入的 URL(https 或 data URI) */
|
|
87
|
-
export function resolveImageMarkdownUrl(data: Record<string, unknown>): string | null {
|
|
88
|
-
const url = typeof data.url === "string" ? data.url : undefined;
|
|
89
|
-
const file = resolveMediaFile(data);
|
|
90
|
-
|
|
91
|
-
if (url && isRemoteUrl(url)) return url;
|
|
92
|
-
if (file && isRemoteUrl(file)) return file;
|
|
93
|
-
|
|
94
|
-
const inline = file ?? (url && isInlineMedia(url) ? url : undefined);
|
|
95
|
-
if (!inline) return null;
|
|
96
|
-
|
|
97
|
-
if (inline.startsWith("base64://")) {
|
|
98
|
-
return `data:image/png;base64,${inline.slice(9)}`;
|
|
99
|
-
}
|
|
100
|
-
if (/^data:[^/]+\/[^;]+;base64,/i.test(inline)) {
|
|
101
|
-
return inline;
|
|
102
|
-
}
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function isTextImageMixedBody(body: MessageSegment[]): boolean {
|
|
107
|
-
if (body.length === 0) return false;
|
|
108
|
-
if (body.some((seg) => !TEXT_IMAGE_MIX_TYPES.has(seg.type))) return false;
|
|
109
|
-
const hasImage = body.some((seg) => seg.type === "image");
|
|
110
|
-
const hasText = body.some((seg) => seg.type === "text" || seg.type === "at");
|
|
111
|
-
return hasImage && hasText;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** 图文混排且含 base64/本地图(无法直接嵌入 Markdown 公网 URL) */
|
|
115
|
-
export function isTextImageMixedWithInlineMedia(body: MessageSegment[]): boolean {
|
|
116
|
-
if (!isTextImageMixedBody(body)) return false;
|
|
117
|
-
return body.some((seg) => {
|
|
118
|
-
if (seg.type !== "image") return false;
|
|
119
|
-
const url = typeof seg.data.url === "string" ? seg.data.url : undefined;
|
|
120
|
-
const file = resolveMediaFile(seg.data);
|
|
121
|
-
if (url && isRemoteUrl(url)) return false;
|
|
122
|
-
if (file && isRemoteUrl(file)) return false;
|
|
123
|
-
return Boolean(resolveMediaFile(seg.data) || (url && isInlineMedia(url)));
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function segmentToMarkdownPart(seg: MessageSegment): string {
|
|
128
|
-
if (seg.type === "text") return textFromSegment(seg);
|
|
129
|
-
if (seg.type === "at") return `<@${String(seg.data.user_id ?? "")}>`;
|
|
130
|
-
if (seg.type === "image") {
|
|
131
|
-
const src = resolveImageMarkdownUrl(seg.data);
|
|
132
|
-
return src ? formatQqMarkdownImage(src, seg.data) : "";
|
|
133
|
-
}
|
|
134
|
-
return "";
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function shouldConvertTextOnly(mode: OutboundMarkdownMode | undefined, bodyText: string): boolean {
|
|
138
|
-
if (mode === false) return false;
|
|
139
|
-
if (mode === true) return bodyText.trim().length > 0;
|
|
140
|
-
return MARKDOWN_HINT.test(bodyText);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function mergeTextImageToMarkdown(
|
|
144
|
-
prefix: MessageSegment[],
|
|
145
|
-
body: MessageSegment[],
|
|
146
|
-
): SendContent | null {
|
|
147
|
-
if (body.some((seg) => seg.type === "image" && !resolveImageMarkdownUrl(seg.data))) {
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
const merged = body.map(segmentToMarkdownPart).join("");
|
|
151
|
-
if (!merged.trim()) return null;
|
|
152
|
-
return [...prefix, segment("markdown", { content: merged })];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* 出站归一化:保留 leading `reply`,将连续 text 或 text+image 合并为单条 markdown 段。
|
|
157
|
-
* 含内联 base64 图的图文混排须走 outbound-mixed-media(msg_type=7),此处不处理。
|
|
158
|
-
*/
|
|
159
|
-
export function normalizeOutboundMarkdown(
|
|
160
|
-
content: SendContent,
|
|
161
|
-
mode: OutboundMarkdownMode | undefined = "auto",
|
|
162
|
-
): SendContent {
|
|
163
|
-
const segments = asOutboundSegments(content);
|
|
164
|
-
if (segments.length === 0) return content;
|
|
165
|
-
|
|
166
|
-
const { prefix, body } = splitReplyPrefix(segments);
|
|
167
|
-
if (body.length === 0) return content;
|
|
168
|
-
if (mode === false) return content;
|
|
169
|
-
|
|
170
|
-
if (body.some((seg) => BLOCKING_RICH_TYPES.has(seg.type))) {
|
|
171
|
-
return content;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (body.some((seg) => seg.type === "markdown")) {
|
|
175
|
-
return content;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (isTextImageMixedBody(body)) {
|
|
179
|
-
if (isTextImageMixedWithInlineMedia(body)) {
|
|
180
|
-
return content;
|
|
181
|
-
}
|
|
182
|
-
const mixed = mergeTextImageToMarkdown(prefix, body);
|
|
183
|
-
return mixed ?? content;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (body.some((seg) => seg.type === "image")) {
|
|
187
|
-
return content;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (body.some((seg) => seg.type !== "text" && seg.type !== "at")) {
|
|
191
|
-
return content;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const merged = body
|
|
195
|
-
.map((seg) => (seg.type === "at"
|
|
196
|
-
? `<@${String(seg.data.user_id ?? "")}>`
|
|
197
|
-
: textFromSegment(seg)))
|
|
198
|
-
.join("");
|
|
199
|
-
|
|
200
|
-
if (!shouldConvertTextOnly(mode, merged)) return content;
|
|
201
|
-
|
|
202
|
-
return [...prefix, segment("markdown", { content: merged })];
|
|
203
|
-
}
|
package/src/outbound-media.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ 官方出站媒体归一化。
|
|
3
|
-
* qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL 上传;
|
|
4
|
-
* base64:// / data:...;base64 须走 data.file 才会解析为 file_data。
|
|
5
|
-
*/
|
|
6
|
-
import type { MessageElement, MessageSegment, SendContent } from "zhin.js";
|
|
7
|
-
|
|
8
|
-
const MEDIA_TYPES = new Set(["image", "audio", "video", "file"]);
|
|
9
|
-
|
|
10
|
-
function isMessageSegment(seg: MessageElement): seg is MessageSegment {
|
|
11
|
-
return typeof seg.type === "string";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function isRemoteUrl(value: string): boolean {
|
|
15
|
-
return /^https?:\/\//i.test(value);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function isInlineBase64(value: string): boolean {
|
|
19
|
-
return value.startsWith("base64://") || /^data:[^/]+\/[^;]+;base64,/i.test(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** 解析 image/audio/video/file 段的本地或内联 payload(供 mixed-media 上传复用) */
|
|
23
|
-
export function resolveMediaFile(data: Record<string, unknown>): string | undefined {
|
|
24
|
-
const url = typeof data.url === "string" ? data.url : undefined;
|
|
25
|
-
const file = typeof data.file === "string" ? data.file : undefined;
|
|
26
|
-
const base64 = typeof data.base64 === "string" ? data.base64 : undefined;
|
|
27
|
-
|
|
28
|
-
if (file && !isRemoteUrl(file)) return file;
|
|
29
|
-
if (url && isInlineBase64(url)) return url;
|
|
30
|
-
if (url && !isRemoteUrl(url) && (url.startsWith("file://") || url.startsWith("/"))) return url;
|
|
31
|
-
if (base64) return base64.startsWith("base64://") ? base64 : `base64://${base64}`;
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function normalizeMediaSegment(seg: MessageSegment): MessageSegment {
|
|
36
|
-
if (!MEDIA_TYPES.has(seg.type)) return seg;
|
|
37
|
-
|
|
38
|
-
const data = { ...seg.data } as Record<string, unknown>;
|
|
39
|
-
const file = resolveMediaFile(data);
|
|
40
|
-
if (!file) return seg;
|
|
41
|
-
|
|
42
|
-
delete data.url;
|
|
43
|
-
delete data.base64;
|
|
44
|
-
data.file = file;
|
|
45
|
-
return { type: seg.type, data } as MessageSegment;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 将 Zhin 出站 image/base64 段转为 qq-official-bot 可识别的 file 字段 */
|
|
49
|
-
export function normalizeOutboundMedia(content: SendContent): SendContent {
|
|
50
|
-
if (typeof content === "string") return content;
|
|
51
|
-
if (!Array.isArray(content)) {
|
|
52
|
-
return isMessageSegment(content) ? normalizeMediaSegment(content) : content;
|
|
53
|
-
}
|
|
54
|
-
return content.map((item) => {
|
|
55
|
-
if (typeof item === "string") return item;
|
|
56
|
-
return isMessageSegment(item) ? normalizeMediaSegment(item) : item;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ 群/私聊图文混排(msg_type=7)。
|
|
3
|
-
* 内联 base64 图无法作为 Markdown 公网 URL,须先上传 file_info 再与文本同条发送。
|
|
4
|
-
*/
|
|
5
|
-
import { randomInt } from "node:crypto";
|
|
6
|
-
import type { MessageSegment, SendContent } from "zhin.js";
|
|
7
|
-
import {
|
|
8
|
-
asOutboundSegments,
|
|
9
|
-
isTextImageMixedWithInlineMedia,
|
|
10
|
-
splitReplyPrefix,
|
|
11
|
-
} from "./outbound-markdown.js";
|
|
12
|
-
import { resolveMediaFile } from "./outbound-media.js";
|
|
13
|
-
|
|
14
|
-
function textFromSegment(seg: MessageSegment): string {
|
|
15
|
-
if (seg.type !== "text") return "";
|
|
16
|
-
return seg.data.text ?? seg.data.content ?? "";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function isRemoteUrl(value: string): boolean {
|
|
20
|
-
return /^https?:\/\//i.test(value);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function extractInlineFileData(source: string): string {
|
|
24
|
-
if (source.startsWith("base64://")) return source.slice(9);
|
|
25
|
-
if (/^data:[^/]+\/[^;]+;base64,/i.test(source)) {
|
|
26
|
-
return source.replace(/^data:[^/]+\/[^;]+;base64,/i, "");
|
|
27
|
-
}
|
|
28
|
-
return source;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function buildQqImageUploadPayload(
|
|
32
|
-
imageSeg: MessageSegment,
|
|
33
|
-
): { file_type: 1; url?: string; file_data?: string } {
|
|
34
|
-
const data = imageSeg.data;
|
|
35
|
-
const url = typeof data.url === "string" ? data.url : undefined;
|
|
36
|
-
const file = resolveMediaFile(data);
|
|
37
|
-
const remote = [url, file].find((item) => item && isRemoteUrl(item));
|
|
38
|
-
if (remote) {
|
|
39
|
-
return { file_type: 1, url: remote };
|
|
40
|
-
}
|
|
41
|
-
const inline = file ?? url;
|
|
42
|
-
if (!inline) {
|
|
43
|
-
throw new Error("QQ 图文混排:图片段缺少 url/file/base64");
|
|
44
|
-
}
|
|
45
|
-
return { file_type: 1, file_data: extractInlineFileData(inline) };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function buildTextImageMixedBodyText(body: MessageSegment[]): string {
|
|
49
|
-
return body
|
|
50
|
-
.map((seg) => {
|
|
51
|
-
if (seg.type === "text") return textFromSegment(seg);
|
|
52
|
-
if (seg.type === "at") return `<@${String(seg.data.user_id ?? "")}>`;
|
|
53
|
-
return "";
|
|
54
|
-
})
|
|
55
|
-
.join("");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function firstImageSegment(body: MessageSegment[]): MessageSegment | undefined {
|
|
59
|
-
return body.find((seg) => seg.type === "image");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function shouldSendTextImageMixedMedia(
|
|
63
|
-
content: SendContent,
|
|
64
|
-
mode: boolean | "auto" | undefined,
|
|
65
|
-
): boolean {
|
|
66
|
-
if (mode === false) return false;
|
|
67
|
-
const { body } = splitReplyPrefix(asOutboundSegments(content));
|
|
68
|
-
return isTextImageMixedWithInlineMedia(body);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface QqMixedMediaMessagePayload {
|
|
72
|
-
msg_type: 7;
|
|
73
|
-
content: string;
|
|
74
|
-
media: { file_info: string };
|
|
75
|
-
msg_seq: number;
|
|
76
|
-
msg_id?: string;
|
|
77
|
-
event_id?: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function buildMixedMediaMessagePayload(
|
|
81
|
-
content: SendContent,
|
|
82
|
-
fileInfo: string,
|
|
83
|
-
): QqMixedMediaMessagePayload {
|
|
84
|
-
const segments = asOutboundSegments(content);
|
|
85
|
-
const { prefix, body } = splitReplyPrefix(segments);
|
|
86
|
-
const payload: QqMixedMediaMessagePayload = {
|
|
87
|
-
msg_type: 7,
|
|
88
|
-
content: buildTextImageMixedBodyText(body),
|
|
89
|
-
media: { file_info: fileInfo },
|
|
90
|
-
msg_seq: randomInt(1, 1_000_000),
|
|
91
|
-
};
|
|
92
|
-
const reply = prefix[prefix.length - 1];
|
|
93
|
-
if (reply?.type === "reply") {
|
|
94
|
-
if (typeof reply.data.event_id === "string") {
|
|
95
|
-
payload.event_id = reply.data.event_id;
|
|
96
|
-
} else if (reply.data.id != null) {
|
|
97
|
-
payload.msg_id = String(reply.data.id);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return payload;
|
|
101
|
-
}
|
package/src/qq-bind-api.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ Bot 扫码绑定协议(内联)
|
|
3
|
-
* Protocol aligned with @tencent-connect/qqbot-connector@1.1.0 qqbot-session — inlined to avoid runtime dependency.
|
|
4
|
-
*/
|
|
5
|
-
import { createDecipheriv, randomBytes } from 'node:crypto';
|
|
6
|
-
|
|
7
|
-
export type QQBotEnv = 'production' | 'test';
|
|
8
|
-
|
|
9
|
-
const HOSTS: Record<QQBotEnv, string> = {
|
|
10
|
-
production: 'q.qq.com',
|
|
11
|
-
test: 'test.q.qq.com',
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export enum BindStatus {
|
|
15
|
-
NONE = 0,
|
|
16
|
-
PENDING = 1,
|
|
17
|
-
COMPLETED = 2,
|
|
18
|
-
EXPIRED = 3,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function getQQBotHost(env: QQBotEnv = 'production'): string {
|
|
22
|
-
return HOSTS[env];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function generateBindKey(): string {
|
|
26
|
-
return randomBytes(32).toString('base64');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function buildConnectUrl(taskId: string, source = 'zhin'): string {
|
|
30
|
-
return `https://${getQQBotHost('production')}/qqbot/openclaw/connect.html?task_id=${encodeURIComponent(taskId)}&source=${encodeURIComponent(source)}&_wv=2`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* AES-256-GCM 解密 base64 密文。
|
|
35
|
-
* 密文格式: IV(12 bytes) + ciphertext + AuthTag(16 bytes)
|
|
36
|
-
*/
|
|
37
|
-
export function decryptSecret(encryptedBase64: string, keyBase64: string): string {
|
|
38
|
-
const key = Buffer.from(keyBase64, 'base64');
|
|
39
|
-
const payload = Buffer.from(encryptedBase64, 'base64');
|
|
40
|
-
const iv = payload.subarray(0, 12);
|
|
41
|
-
const authTag = payload.subarray(payload.length - 16);
|
|
42
|
-
const ciphertext = payload.subarray(12, payload.length - 16);
|
|
43
|
-
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
44
|
-
decipher.setAuthTag(authTag);
|
|
45
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface LiteApiResponse<T> {
|
|
49
|
-
retcode: number;
|
|
50
|
-
msg?: string;
|
|
51
|
-
data?: T;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function postJson<T>(
|
|
55
|
-
url: string,
|
|
56
|
-
body: Record<string, unknown>,
|
|
57
|
-
timeoutMs: number,
|
|
58
|
-
): Promise<LiteApiResponse<T>> {
|
|
59
|
-
const controller = new AbortController();
|
|
60
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
61
|
-
try {
|
|
62
|
-
const res = await fetch(url, {
|
|
63
|
-
method: 'POST',
|
|
64
|
-
headers: {
|
|
65
|
-
'Content-Type': 'application/json',
|
|
66
|
-
Accept: 'application/json',
|
|
67
|
-
},
|
|
68
|
-
body: JSON.stringify(body),
|
|
69
|
-
signal: controller.signal,
|
|
70
|
-
});
|
|
71
|
-
if (!res.ok) {
|
|
72
|
-
throw new Error(`HTTP ${res.status} from ${url}`);
|
|
73
|
-
}
|
|
74
|
-
return (await res.json()) as LiteApiResponse<T>;
|
|
75
|
-
} finally {
|
|
76
|
-
clearTimeout(timer);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface BindTaskResult {
|
|
81
|
-
taskId: string;
|
|
82
|
-
key: string;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function createBindTask(
|
|
86
|
-
env: QQBotEnv = 'production',
|
|
87
|
-
timeoutMs = 10_000,
|
|
88
|
-
): Promise<BindTaskResult> {
|
|
89
|
-
const url = `https://${getQQBotHost(env)}/lite/create_bind_task`;
|
|
90
|
-
const key = generateBindKey();
|
|
91
|
-
const res = await postJson<{ task_id?: string }>(url, { key }, timeoutMs);
|
|
92
|
-
if (res.retcode !== 0) {
|
|
93
|
-
throw new Error(res.msg ?? 'create_bind_task failed');
|
|
94
|
-
}
|
|
95
|
-
if (!res.data?.task_id) {
|
|
96
|
-
throw new Error('create_bind_task: missing task_id');
|
|
97
|
-
}
|
|
98
|
-
return { taskId: res.data.task_id, key };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export interface PollBindResultOk {
|
|
102
|
-
status: BindStatus;
|
|
103
|
-
botAppId: string;
|
|
104
|
-
botEncryptSecret: string;
|
|
105
|
-
/** 扫码绑定开发者 QQ 的 OpenID(poll_bind_result 返回) */
|
|
106
|
-
userOpenId: string;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function parsePollBindResultData(data: {
|
|
110
|
-
status?: number;
|
|
111
|
-
bot_appid?: string | number;
|
|
112
|
-
bot_encrypt_secret?: string;
|
|
113
|
-
user_openid?: string;
|
|
114
|
-
userOpenid?: string;
|
|
115
|
-
openid?: string;
|
|
116
|
-
} | undefined): PollBindResultOk {
|
|
117
|
-
return {
|
|
118
|
-
status: (data?.status ?? BindStatus.NONE) as BindStatus,
|
|
119
|
-
botAppId: String(data?.bot_appid ?? ''),
|
|
120
|
-
botEncryptSecret: data?.bot_encrypt_secret ?? '',
|
|
121
|
-
userOpenId: String(
|
|
122
|
-
data?.user_openid ?? data?.userOpenid ?? data?.openid ?? '',
|
|
123
|
-
),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export async function pollBindResult(
|
|
128
|
-
taskId: string,
|
|
129
|
-
env: QQBotEnv = 'production',
|
|
130
|
-
timeoutMs = 10_000,
|
|
131
|
-
): Promise<PollBindResultOk> {
|
|
132
|
-
const url = `https://${getQQBotHost(env)}/lite/poll_bind_result`;
|
|
133
|
-
const res = await postJson<{
|
|
134
|
-
status?: number;
|
|
135
|
-
bot_appid?: string | number;
|
|
136
|
-
bot_encrypt_secret?: string;
|
|
137
|
-
user_openid?: string;
|
|
138
|
-
userOpenid?: string;
|
|
139
|
-
openid?: string;
|
|
140
|
-
}>(url, { task_id: taskId }, timeoutMs);
|
|
141
|
-
if (res.retcode !== 0) {
|
|
142
|
-
throw new Error(res.msg ?? 'poll_bind_result failed');
|
|
143
|
-
}
|
|
144
|
-
return parsePollBindResultData(res.data);
|
|
145
|
-
}
|
package/src/qq-bind-flow.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ Bot 扫码绑定流程(内联,等价 qqbot-connector startQrConnect)
|
|
3
|
-
*/
|
|
4
|
-
import {
|
|
5
|
-
BindStatus,
|
|
6
|
-
buildConnectUrl,
|
|
7
|
-
createBindTask,
|
|
8
|
-
decryptSecret,
|
|
9
|
-
pollBindResult,
|
|
10
|
-
} from './qq-bind-api.js';
|
|
11
|
-
|
|
12
|
-
const POLL_INTERVAL_MS = 2000;
|
|
13
|
-
|
|
14
|
-
export interface QqBindCredentials {
|
|
15
|
-
appId: string;
|
|
16
|
-
appSecret: string;
|
|
17
|
-
userOpenId?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface QqBindCallbacks {
|
|
21
|
-
onSuccess: (credentials: QqBindCredentials[]) => void;
|
|
22
|
-
onFailure: (error: Error) => void;
|
|
23
|
-
onQrDisplayed?: (url: string) => void | Promise<void>;
|
|
24
|
-
onQrExpired?: () => void | Promise<void>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface QqBindFlowOptions {
|
|
28
|
-
signal?: AbortSignal;
|
|
29
|
-
source?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
33
|
-
return new Promise((resolve, reject) => {
|
|
34
|
-
if (signal?.aborted) {
|
|
35
|
-
reject(new DOMException('Aborted', 'AbortError'));
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
const timer = setTimeout(resolve, ms);
|
|
39
|
-
signal?.addEventListener(
|
|
40
|
-
'abort',
|
|
41
|
-
() => {
|
|
42
|
-
clearTimeout(timer);
|
|
43
|
-
reject(new DOMException('Aborted', 'AbortError'));
|
|
44
|
-
},
|
|
45
|
-
{ once: true },
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function pollUntilResult(
|
|
51
|
-
taskId: string,
|
|
52
|
-
key: string,
|
|
53
|
-
signal?: AbortSignal,
|
|
54
|
-
): Promise<{ outcome: 'scanned'; appId: string; appSecret: string; userOpenId: string } | { outcome: 'expired' }> {
|
|
55
|
-
while (!signal?.aborted) {
|
|
56
|
-
let result;
|
|
57
|
-
try {
|
|
58
|
-
result = await pollBindResult(taskId);
|
|
59
|
-
} catch {
|
|
60
|
-
await sleep(POLL_INTERVAL_MS, signal);
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (result.status === BindStatus.COMPLETED) {
|
|
64
|
-
const appSecret = decryptSecret(result.botEncryptSecret, key);
|
|
65
|
-
return {
|
|
66
|
-
outcome: 'scanned',
|
|
67
|
-
appId: result.botAppId,
|
|
68
|
-
appSecret,
|
|
69
|
-
userOpenId: result.userOpenId,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
if (result.status === BindStatus.EXPIRED) {
|
|
73
|
-
return { outcome: 'expired' };
|
|
74
|
-
}
|
|
75
|
-
await sleep(POLL_INTERVAL_MS, signal);
|
|
76
|
-
}
|
|
77
|
-
throw new DOMException('Aborted', 'AbortError');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
|
|
82
|
-
*/
|
|
83
|
-
export function startQqBindFlow(
|
|
84
|
-
callbacks: QqBindCallbacks,
|
|
85
|
-
options: QqBindFlowOptions = {},
|
|
86
|
-
): () => void {
|
|
87
|
-
const controller = new AbortController();
|
|
88
|
-
const signal = options.signal
|
|
89
|
-
? AbortSignal.any([controller.signal, options.signal])
|
|
90
|
-
: controller.signal;
|
|
91
|
-
|
|
92
|
-
void (async () => {
|
|
93
|
-
for (;;) {
|
|
94
|
-
if (signal.aborted) {
|
|
95
|
-
throw new DOMException('Aborted', 'AbortError');
|
|
96
|
-
}
|
|
97
|
-
let task;
|
|
98
|
-
try {
|
|
99
|
-
task = await createBindTask();
|
|
100
|
-
} catch (err) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`获取绑定任务失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
103
|
-
{ cause: err },
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
const connectUrl = buildConnectUrl(task.taskId, options.source ?? 'zhin');
|
|
107
|
-
await callbacks.onQrDisplayed?.(connectUrl);
|
|
108
|
-
const pollResult = await pollUntilResult(task.taskId, task.key, signal);
|
|
109
|
-
if (pollResult.outcome === 'scanned') {
|
|
110
|
-
callbacks.onSuccess([{
|
|
111
|
-
appId: pollResult.appId,
|
|
112
|
-
appSecret: pollResult.appSecret,
|
|
113
|
-
userOpenId: pollResult.userOpenId || undefined,
|
|
114
|
-
}]);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
await callbacks.onQrExpired?.();
|
|
118
|
-
}
|
|
119
|
-
})().catch((err) => {
|
|
120
|
-
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
callbacks.onFailure(err instanceof Error ? err : new Error(String(err)));
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
return () => controller.abort();
|
|
127
|
-
}
|
package/src/qq-bind-persist.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { runtimeCwd } from 'zhin.js';
|
|
4
|
-
|
|
5
|
-
export interface QqCredentialEnvKeys {
|
|
6
|
-
appidKey: string;
|
|
7
|
-
secretKey: string;
|
|
8
|
-
appidRef: string;
|
|
9
|
-
secretRef: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** 按 endpoint 名称生成唯一 env 键(如 `QQ_MOCK_QQ_BOT_APPID`) */
|
|
13
|
-
export function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvKeys {
|
|
14
|
-
const slug = endpointName.replace(/[^a-zA-Z0-9_]/g, '_').toUpperCase();
|
|
15
|
-
const appidKey = `QQ_${slug}_APPID`;
|
|
16
|
-
const secretKey = `QQ_${slug}_SECRET`;
|
|
17
|
-
return {
|
|
18
|
-
appidKey,
|
|
19
|
-
secretKey,
|
|
20
|
-
appidRef: `\${${appidKey}}`,
|
|
21
|
-
secretRef: `\${${secretKey}}`,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function resolveProjectRoot(): string {
|
|
26
|
-
const envRoot = process.env.ZHIN_PROJECT_ROOT?.trim();
|
|
27
|
-
return path.resolve(envRoot || runtimeCwd());
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function escapeRegExp(text: string): string {
|
|
31
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function upsertEnvLine(content: string, key: string, value: string): string {
|
|
35
|
-
const lineRe = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, 'm');
|
|
36
|
-
const newLine = `${key}=${value}`;
|
|
37
|
-
if (lineRe.test(content)) {
|
|
38
|
-
return content.replace(lineRe, newLine);
|
|
39
|
-
}
|
|
40
|
-
const trimmed = content.replace(/\s*$/, '');
|
|
41
|
-
if (trimmed.length === 0) {
|
|
42
|
-
return `${newLine}\n`;
|
|
43
|
-
}
|
|
44
|
-
return `${trimmed}\n${newLine}\n`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
|
|
48
|
-
export function persistQqCredentialsToEnv(
|
|
49
|
-
endpointName: string,
|
|
50
|
-
appId: string,
|
|
51
|
-
appSecret: string,
|
|
52
|
-
projectRoot?: string,
|
|
53
|
-
): QqCredentialEnvKeys {
|
|
54
|
-
const keys = buildQqCredentialEnvKeys(endpointName);
|
|
55
|
-
const root = projectRoot ?? resolveProjectRoot();
|
|
56
|
-
const envPath = path.join(root, '.env');
|
|
57
|
-
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
|
|
58
|
-
|
|
59
|
-
const entries: Record<string, string> = {
|
|
60
|
-
[keys.appidKey]: appId,
|
|
61
|
-
[keys.secretKey]: appSecret,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
for (const [key, value] of Object.entries(entries)) {
|
|
65
|
-
content = upsertEnvLine(content, key, value);
|
|
66
|
-
process.env[key] = value;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
fs.writeFileSync(envPath, content);
|
|
70
|
-
return keys;
|
|
71
|
-
}
|