@zhin.js/adapter-qq 4.0.2 → 5.0.1
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 +28 -0
- package/README.md +54 -0
- package/dist/index.js +2 -2
- package/lib/adapter.d.ts +14 -3
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +25 -4
- package/lib/adapter.js.map +1 -1
- package/lib/endpoint.d.ts +6 -1
- package/lib/endpoint.d.ts.map +1 -1
- package/lib/endpoint.js +80 -3
- package/lib/endpoint.js.map +1 -1
- package/lib/group-at-normalize.d.ts.map +1 -1
- package/lib/group-at-normalize.js +2 -5
- package/lib/group-at-normalize.js.map +1 -1
- package/lib/inbound-action.d.ts +15 -0
- package/lib/inbound-action.d.ts.map +1 -0
- package/lib/inbound-action.js +57 -0
- package/lib/inbound-action.js.map +1 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +11 -4
- package/lib/index.js.map +1 -1
- package/lib/mention-enrich.d.ts +12 -0
- package/lib/mention-enrich.d.ts.map +1 -0
- package/lib/mention-enrich.js +52 -0
- package/lib/mention-enrich.js.map +1 -0
- package/lib/outbound-keyboard.d.ts +4 -0
- package/lib/outbound-keyboard.d.ts.map +1 -0
- package/lib/outbound-keyboard.js +96 -0
- package/lib/outbound-keyboard.js.map +1 -0
- package/lib/outbound-markdown.d.ts +16 -4
- package/lib/outbound-markdown.d.ts.map +1 -1
- package/lib/outbound-markdown.js +116 -17
- package/lib/outbound-markdown.js.map +1 -1
- package/lib/outbound-media.d.ts +2 -0
- package/lib/outbound-media.d.ts.map +1 -1
- package/lib/outbound-media.js +2 -1
- package/lib/outbound-media.js.map +1 -1
- package/lib/outbound-mixed-media.d.ts +21 -0
- package/lib/outbound-mixed-media.d.ts.map +1 -0
- package/lib/outbound-mixed-media.js +78 -0
- package/lib/outbound-mixed-media.js.map +1 -0
- package/lib/platform-permit.js +2 -2
- package/lib/qq-bind-api.d.ts +37 -0
- package/lib/qq-bind-api.d.ts.map +1 -0
- package/lib/qq-bind-api.js +90 -0
- package/lib/qq-bind-api.js.map +1 -0
- package/lib/qq-bind-flow.d.ts +20 -0
- package/lib/qq-bind-flow.d.ts.map +1 -0
- package/lib/qq-bind-flow.js +86 -0
- package/lib/qq-bind-flow.js.map +1 -0
- package/lib/qq-bind-persist.d.ts +11 -0
- package/lib/qq-bind-persist.d.ts.map +1 -0
- package/lib/qq-bind-persist.js +52 -0
- package/lib/qq-bind-persist.js.map +1 -0
- package/lib/qq-endpoint-manager.d.ts +16 -0
- package/lib/qq-endpoint-manager.d.ts.map +1 -0
- package/lib/qq-endpoint-manager.js +159 -0
- package/lib/qq-endpoint-manager.js.map +1 -0
- package/lib/segment-mapper.d.ts +2 -0
- package/lib/segment-mapper.d.ts.map +1 -0
- package/lib/segment-mapper.js +2 -0
- package/lib/segment-mapper.js.map +1 -0
- package/lib/types.d.ts +7 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +16 -16
- package/skills/qq/PERMITS.md +1 -1
- package/src/adapter.ts +28 -4
- package/src/endpoint.ts +118 -6
- package/src/group-at-normalize.ts +2 -4
- package/src/inbound-action.ts +87 -0
- package/src/index.ts +13 -5
- package/src/mention-enrich.ts +71 -0
- package/src/outbound-keyboard.ts +118 -0
- package/src/outbound-markdown.ts +127 -17
- package/src/outbound-media.ts +2 -1
- package/src/outbound-mixed-media.ts +101 -0
- package/src/platform-permit.ts +2 -2
- package/src/qq-bind-api.ts +145 -0
- package/src/qq-bind-flow.ts +127 -0
- package/src/qq-bind-persist.ts +71 -0
- package/src/qq-endpoint-manager.ts +199 -0
- package/src/segment-mapper.ts +1 -0
- package/src/types.ts +7 -0
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* QQ 官方适配器入口:类型扩展、导出、注册
|
|
3
3
|
*/
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { usePlugin, type Plugin, type
|
|
5
|
+
import { usePlugin, type Plugin, type ISceneManagement, createSceneManagementTools, type ToolFeature } from "zhin.js";
|
|
6
6
|
import type { Router } from "@zhin.js/host-router";
|
|
7
7
|
import { QQAdapter } from "./adapter.js";
|
|
8
8
|
import { PageManager } from "@zhin.js/host-api";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
qqGuildPermitResolver,
|
|
12
12
|
registerQqPlatformPermitChecker,
|
|
13
13
|
} from "./platform-permit.js";
|
|
14
|
+
import { disposeQqEndpointProvision } from "./qq-endpoint-manager.js";
|
|
14
15
|
|
|
15
16
|
declare module "zhin.js" {
|
|
16
17
|
namespace Plugin {
|
|
@@ -30,9 +31,12 @@ declare module "zhin.js" {
|
|
|
30
31
|
export * from "./types.js";
|
|
31
32
|
export { QQEndpoint } from "./endpoint.js";
|
|
32
33
|
export { QQAdapter } from "./adapter.js";
|
|
34
|
+
export { startQqBindFlow } from "./qq-bind-flow.js";
|
|
35
|
+
export * from "./qq-bind-api.js";
|
|
36
|
+
export { QqEndpointManager, disposeQqEndpointProvision } from "./qq-endpoint-manager.js";
|
|
33
37
|
|
|
34
38
|
const plugin = usePlugin();
|
|
35
|
-
const { provide, useContext } = plugin;
|
|
39
|
+
const { provide, useContext, onDispose } = plugin;
|
|
36
40
|
|
|
37
41
|
useContext("router", (router: Router) => {
|
|
38
42
|
provide({
|
|
@@ -52,12 +56,12 @@ useContext("router", (router: Router) => {
|
|
|
52
56
|
useContext('tool', 'qq', (toolService: ToolFeature, qq: QQAdapter) => {
|
|
53
57
|
const disposers: (() => void)[] = [];
|
|
54
58
|
disposers.push(registerQqPlatformPermitChecker());
|
|
55
|
-
const
|
|
56
|
-
qq as unknown as
|
|
59
|
+
const sceneTools = createSceneManagementTools(
|
|
60
|
+
qq as unknown as ISceneManagement,
|
|
57
61
|
'qq',
|
|
58
62
|
{ permitResolver: qqGuildPermitResolver, registerChecker: false },
|
|
59
63
|
);
|
|
60
|
-
disposers.push(...
|
|
64
|
+
disposers.push(...sceneTools.map(t => toolService.addTool(t, plugin.name)));
|
|
61
65
|
|
|
62
66
|
// 获取频道列表
|
|
63
67
|
disposers.push(toolService.addTool({
|
|
@@ -361,3 +365,7 @@ useContext("router", "qq", (router: any, qq: QQAdapter) => {
|
|
|
361
365
|
}
|
|
362
366
|
});
|
|
363
367
|
});
|
|
368
|
+
|
|
369
|
+
onDispose(() => {
|
|
370
|
+
disposeQqEndpointProvision();
|
|
371
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Segment } from 'zhin.js';
|
|
2
|
+
import type { QQAdapter } from './adapter.js';
|
|
3
|
+
|
|
4
|
+
type MentionWire = {
|
|
5
|
+
id?: string;
|
|
6
|
+
member_openid?: string;
|
|
7
|
+
username?: string;
|
|
8
|
+
nickname?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type QQEndpointLike = {
|
|
12
|
+
$id: string;
|
|
13
|
+
$platformUserId?: string;
|
|
14
|
+
$config: { name?: string; appid?: string; nickname?: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function mentionWireId(row: MentionWire): string {
|
|
18
|
+
return String(row.id ?? row.member_openid ?? '').trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mentionWireName(row: MentionWire): string | undefined {
|
|
22
|
+
const name = row.username ?? row.nickname;
|
|
23
|
+
return typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function endpointLookupIds(endpoint: QQEndpointLike): string[] {
|
|
27
|
+
const cfg = endpoint.$config;
|
|
28
|
+
return [
|
|
29
|
+
endpoint.$platformUserId,
|
|
30
|
+
cfg.appid,
|
|
31
|
+
cfg.name,
|
|
32
|
+
endpoint.$id,
|
|
33
|
+
].filter((id): id is string => Boolean(id)).map(String);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveEndpointDisplayName(endpoint: QQEndpointLike): string | undefined {
|
|
37
|
+
const cfg = endpoint.$config;
|
|
38
|
+
return cfg.nickname?.trim() || cfg.name?.trim() || endpoint.$id;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 为 canonical mention 段补齐展示名(平台 payload / endpoint 配置)。 */
|
|
42
|
+
export function enrichCanonicalMentionNames(
|
|
43
|
+
segments: Segment[],
|
|
44
|
+
adapter: QQAdapter,
|
|
45
|
+
wireMentions?: MentionWire[],
|
|
46
|
+
): Segment[] {
|
|
47
|
+
const nameById = new Map<string, string>();
|
|
48
|
+
for (const row of wireMentions ?? []) {
|
|
49
|
+
const id = mentionWireId(row);
|
|
50
|
+
const name = mentionWireName(row);
|
|
51
|
+
if (id && name) nameById.set(id, name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const endpoint of adapter.endpoints.values()) {
|
|
55
|
+
const display = resolveEndpointDisplayName(endpoint);
|
|
56
|
+
if (!display) continue;
|
|
57
|
+
for (const id of endpointLookupIds(endpoint)) {
|
|
58
|
+
if (!nameById.has(id)) nameById.set(id, display);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return segments.map((seg) => {
|
|
63
|
+
if (seg.type !== 'mention') return seg;
|
|
64
|
+
const data = seg.data as { target?: string; name?: string };
|
|
65
|
+
const target = String(data.target ?? '').trim();
|
|
66
|
+
if (!target || data.name) return seg;
|
|
67
|
+
const name = nameById.get(target);
|
|
68
|
+
if (!name) return seg;
|
|
69
|
+
return { ...seg, data: { target, name } };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将 core keyboard 段转为 qq-official-bot 可识别的 button / markdown 段。
|
|
3
|
+
* @see https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html
|
|
4
|
+
*/
|
|
5
|
+
import type { ButtonData, KeyboardSegmentData } from "zhin.js";
|
|
6
|
+
import type { MessageSegment, SendContent } from "zhin.js";
|
|
7
|
+
import { isKeyboardSegment, KeyboardSegment } from "zhin.js";
|
|
8
|
+
|
|
9
|
+
function asKeyboardData(item: unknown): KeyboardSegmentData | null {
|
|
10
|
+
if (item instanceof KeyboardSegment) return item.data;
|
|
11
|
+
if (item != null && typeof item === "object" && isKeyboardSegment(item as MessageSegment)) {
|
|
12
|
+
return (item as MessageSegment).data as KeyboardSegmentData;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type QqButton = {
|
|
18
|
+
id: string;
|
|
19
|
+
render_data: { label: string; visited_label: string; style: number };
|
|
20
|
+
action: {
|
|
21
|
+
type: number;
|
|
22
|
+
permission: { type: number };
|
|
23
|
+
data: string;
|
|
24
|
+
click_limit: number;
|
|
25
|
+
unsupport_tips: string;
|
|
26
|
+
enter?: boolean;
|
|
27
|
+
reply?: boolean;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function coreButtonToQq(btn: ButtonData): QqButton {
|
|
32
|
+
const isCommand = btn.mode === 'command';
|
|
33
|
+
const action: QqButton['action'] = {
|
|
34
|
+
type: isCommand ? 2 : 1,
|
|
35
|
+
permission: { type: 2 },
|
|
36
|
+
data: btn.payload,
|
|
37
|
+
click_limit: btn.disabled ? 0 : 10,
|
|
38
|
+
unsupport_tips: btn.disabled ? "该按钮不可用" : "",
|
|
39
|
+
};
|
|
40
|
+
if (isCommand) {
|
|
41
|
+
if (btn.command?.enter != null) action.enter = btn.command.enter;
|
|
42
|
+
if (btn.command?.reply != null) action.reply = btn.command.reply;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
id: btn.id,
|
|
46
|
+
render_data: {
|
|
47
|
+
label: btn.label,
|
|
48
|
+
visited_label: btn.label,
|
|
49
|
+
style: 0,
|
|
50
|
+
},
|
|
51
|
+
action,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function keyboardDataToQqSegments(data: KeyboardSegmentData): MessageSegment[] {
|
|
56
|
+
const out: MessageSegment[] = [];
|
|
57
|
+
for (const row of data.rows) {
|
|
58
|
+
out.push({
|
|
59
|
+
type: "button",
|
|
60
|
+
data: {
|
|
61
|
+
buttons: row.map(coreButtonToQq),
|
|
62
|
+
},
|
|
63
|
+
} as MessageSegment);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** QQ 官方出站:keyboard → button 行;前置 text 合并为 markdown(按钮消息需带 markdown) */
|
|
69
|
+
export function expandKeyboardSegmentsForQq(content: SendContent): SendContent {
|
|
70
|
+
const items = Array.isArray(content) ? content : [content];
|
|
71
|
+
const out: (string | MessageSegment)[] = [];
|
|
72
|
+
const textParts: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
if (typeof item === "string") {
|
|
76
|
+
textParts.push(item);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const keyboardData = asKeyboardData(item);
|
|
80
|
+
if (keyboardData) {
|
|
81
|
+
if (textParts.length > 0) {
|
|
82
|
+
out.push({
|
|
83
|
+
type: "markdown",
|
|
84
|
+
data: { content: textParts.join("\n") },
|
|
85
|
+
} as MessageSegment);
|
|
86
|
+
textParts.length = 0;
|
|
87
|
+
}
|
|
88
|
+
out.push(...keyboardDataToQqSegments(keyboardData));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (item.type === "text") {
|
|
93
|
+
const t = item.data?.text ?? item.data?.content ?? "";
|
|
94
|
+
if (t) textParts.push(String(t));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (textParts.length > 0) {
|
|
99
|
+
out.push({
|
|
100
|
+
type: "markdown",
|
|
101
|
+
data: { content: textParts.join("\n") },
|
|
102
|
+
} as MessageSegment);
|
|
103
|
+
textParts.length = 0;
|
|
104
|
+
}
|
|
105
|
+
out.push(item as MessageSegment);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (textParts.length > 0) {
|
|
109
|
+
out.push({
|
|
110
|
+
type: "markdown",
|
|
111
|
+
data: { content: textParts.join("\n") },
|
|
112
|
+
} as MessageSegment);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (out.length === 0) return { type: "text", data: { text: "" } };
|
|
116
|
+
if (out.length === 1) return out[0]!;
|
|
117
|
+
return out;
|
|
118
|
+
}
|
package/src/outbound-markdown.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 将 AI 出站纯文本段合并为 QQ Markdown 消息(msg_type=2)。
|
|
3
|
-
*
|
|
3
|
+
* 图文混排时合并为单条 Markdown(图片语法见 QQ 官方文档)。
|
|
4
4
|
*/
|
|
5
5
|
import { segment, type MessageElement, type MessageSegment, type SendContent } from "zhin.js";
|
|
6
|
+
import { resolveMediaFile } from "./outbound-media.js";
|
|
6
7
|
|
|
7
8
|
function isMessageSegment(seg: MessageElement): seg is MessageSegment {
|
|
8
9
|
return typeof seg.type === "string";
|
|
@@ -14,8 +15,7 @@ export type OutboundMarkdownMode = boolean | "auto";
|
|
|
14
15
|
const MARKDOWN_HINT =
|
|
15
16
|
/(?:\*\*|__|`|\[.+\]\(.+\)|^#{1,6}\s|^>\s|^\s*[-*+]\s|^```|\|.+\|)/m;
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
-
"image",
|
|
18
|
+
const BLOCKING_RICH_TYPES = new Set([
|
|
19
19
|
"audio",
|
|
20
20
|
"video",
|
|
21
21
|
"file",
|
|
@@ -27,11 +27,13 @@ const BODY_RICH_TYPES = new Set([
|
|
|
27
27
|
"face",
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
const TEXT_IMAGE_MIX_TYPES = new Set(["text", "at", "image"]);
|
|
31
|
+
|
|
30
32
|
function textSegment(text: string): MessageSegment {
|
|
31
33
|
return { type: "text", data: { text } };
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
function
|
|
36
|
+
export function asOutboundSegments(content: SendContent): MessageSegment[] {
|
|
35
37
|
if (typeof content === "string") return [textSegment(content)];
|
|
36
38
|
if (!Array.isArray(content)) {
|
|
37
39
|
return isMessageSegment(content) ? [content] : [];
|
|
@@ -41,39 +43,147 @@ function asSegments(content: SendContent): MessageSegment[] {
|
|
|
41
43
|
);
|
|
42
44
|
}
|
|
43
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
|
+
|
|
44
59
|
function textFromSegment(seg: MessageSegment): string {
|
|
45
60
|
if (seg.type !== "text") return "";
|
|
46
61
|
return seg.data.text ?? seg.data.content ?? "";
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
function
|
|
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 {
|
|
50
138
|
if (mode === false) return false;
|
|
51
139
|
if (mode === true) return bodyText.trim().length > 0;
|
|
52
140
|
return MARKDOWN_HINT.test(bodyText);
|
|
53
141
|
}
|
|
54
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
|
+
|
|
55
155
|
/**
|
|
56
|
-
* 出站归一化:保留 leading `reply`,将连续 text 合并为单条 markdown 段。
|
|
57
|
-
*
|
|
156
|
+
* 出站归一化:保留 leading `reply`,将连续 text 或 text+image 合并为单条 markdown 段。
|
|
157
|
+
* 含内联 base64 图的图文混排须走 outbound-mixed-media(msg_type=7),此处不处理。
|
|
58
158
|
*/
|
|
59
159
|
export function normalizeOutboundMarkdown(
|
|
60
160
|
content: SendContent,
|
|
61
161
|
mode: OutboundMarkdownMode | undefined = "auto",
|
|
62
162
|
): SendContent {
|
|
63
|
-
const segments =
|
|
163
|
+
const segments = asOutboundSegments(content);
|
|
64
164
|
if (segments.length === 0) return content;
|
|
65
165
|
|
|
66
|
-
const prefix
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
|
71
172
|
}
|
|
72
173
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
75
185
|
|
|
76
|
-
if (body.some((seg) =>
|
|
186
|
+
if (body.some((seg) => seg.type === "image")) {
|
|
77
187
|
return content;
|
|
78
188
|
}
|
|
79
189
|
|
|
@@ -87,7 +197,7 @@ export function normalizeOutboundMarkdown(
|
|
|
87
197
|
: textFromSegment(seg)))
|
|
88
198
|
.join("");
|
|
89
199
|
|
|
90
|
-
if (!
|
|
200
|
+
if (!shouldConvertTextOnly(mode, merged)) return content;
|
|
91
201
|
|
|
92
202
|
return [...prefix, segment("markdown", { content: merged })];
|
|
93
203
|
}
|
package/src/outbound-media.ts
CHANGED
|
@@ -19,7 +19,8 @@ function isInlineBase64(value: string): boolean {
|
|
|
19
19
|
return value.startsWith("base64://") || /^data:[^/]+\/[^;]+;base64,/i.test(value);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/** 解析 image/audio/video/file 段的本地或内联 payload(供 mixed-media 上传复用) */
|
|
23
|
+
export function resolveMediaFile(data: Record<string, unknown>): string | undefined {
|
|
23
24
|
const url = typeof data.url === "string" ? data.url : undefined;
|
|
24
25
|
const file = typeof data.file === "string" ? data.file : undefined;
|
|
25
26
|
const base64 = typeof data.base64 === "string" ? data.base64 : undefined;
|
|
@@ -0,0 +1,101 @@
|
|
|
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/platform-permit.ts
CHANGED
|
@@ -12,8 +12,8 @@ export function platformPermit(perm: string): string {
|
|
|
12
12
|
|
|
13
13
|
/** 官方频道工厂工具:映射到 guild 语义,不用 group_* */
|
|
14
14
|
const FACTORY_PERM_MAP: Record<string, string> = {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
scene_admin: 'guild_admin',
|
|
16
|
+
scene_owner: 'guild_owner',
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
export function qqGuildPermitResolver(logicalPerm: string): string {
|