@zhin.js/adapter-qq 5.0.2 → 6.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +75 -409
  3. package/adapters/qq.ts +51 -0
  4. package/agent/tools/add_role.ts +2 -2
  5. package/agent/tools/channel_info.ts +2 -2
  6. package/agent/tools/create_role.ts +2 -2
  7. package/agent/tools/list_channels.ts +2 -2
  8. package/agent/tools/list_guilds.ts +2 -2
  9. package/agent/tools/list_roles.ts +2 -2
  10. package/agent/tools/member_detail.ts +2 -2
  11. package/agent/tools/remove_role.ts +2 -2
  12. package/commands/endpoint/add/[name:string=].ts +21 -0
  13. package/commands/endpoint/cancel.ts +15 -0
  14. package/commands/endpoint/list.ts +10 -0
  15. package/commands/endpoint/remove/[name:string].ts +15 -0
  16. package/lib/endpoint.d.ts +75 -0
  17. package/lib/endpoint.js +358 -0
  18. package/lib/index.d.ts +6 -0
  19. package/lib/index.js +6 -0
  20. package/lib/platform-permit.d.ts +16 -0
  21. package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
  22. package/lib/protocol.d.ts +96 -0
  23. package/lib/protocol.js +129 -0
  24. package/lib/qq-agent-deps.d.ts +21 -0
  25. package/lib/qq-agent-deps.js +30 -0
  26. package/lib/qq-bind-api.d.ts +36 -0
  27. package/lib/{src/qq-bind-api.js → qq-bind-api.js} +0 -1
  28. package/lib/qq-bind-flow.d.ts +19 -0
  29. package/lib/{src/qq-bind-flow.js → qq-bind-flow.js} +0 -1
  30. package/lib/qq-bind-persist.d.ts +12 -0
  31. package/lib/{src/qq-bind-persist.js → qq-bind-persist.js} +4 -5
  32. package/lib/qq-endpoint-commands.d.ts +25 -0
  33. package/lib/qq-endpoint-commands.js +147 -0
  34. package/lib/qq-endpoint-config.d.ts +17 -0
  35. package/lib/qq-endpoint-config.js +110 -0
  36. package/lib/qq-runtime-state.d.ts +17 -0
  37. package/lib/qq-runtime-state.js +13 -0
  38. package/lib/webhook.d.ts +18 -0
  39. package/lib/webhook.js +100 -0
  40. package/lib/ws.d.ts +24 -0
  41. package/lib/ws.js +118 -0
  42. package/package.json +51 -40
  43. package/plugin.ts +16 -0
  44. package/schema.json +85 -0
  45. package/src/endpoint.ts +362 -596
  46. package/src/index.ts +56 -186
  47. package/src/platform-permit.ts +1 -1
  48. package/src/protocol.ts +229 -0
  49. package/src/qq-agent-deps.ts +49 -12
  50. package/src/qq-bind-persist.ts +4 -4
  51. package/src/qq-endpoint-commands.ts +174 -0
  52. package/src/qq-endpoint-config.ts +135 -0
  53. package/src/qq-runtime-state.ts +36 -0
  54. package/src/webhook.ts +127 -0
  55. package/src/ws.ts +172 -0
  56. package/client/Dashboard.tsx +0 -230
  57. package/client/index.tsx +0 -11
  58. package/client/tsconfig.json +0 -7
  59. package/client/utils/api.ts +0 -30
  60. package/dist/index.js +0 -30
  61. package/lib/agent/tools/add_role.js +0 -23
  62. package/lib/agent/tools/add_role.js.map +0 -1
  63. package/lib/agent/tools/channel_info.js +0 -19
  64. package/lib/agent/tools/channel_info.js.map +0 -1
  65. package/lib/agent/tools/create_role.js +0 -22
  66. package/lib/agent/tools/create_role.js.map +0 -1
  67. package/lib/agent/tools/list_channels.js +0 -20
  68. package/lib/agent/tools/list_channels.js.map +0 -1
  69. package/lib/agent/tools/list_guilds.js +0 -17
  70. package/lib/agent/tools/list_guilds.js.map +0 -1
  71. package/lib/agent/tools/list_roles.js +0 -20
  72. package/lib/agent/tools/list_roles.js.map +0 -1
  73. package/lib/agent/tools/member_detail.js +0 -20
  74. package/lib/agent/tools/member_detail.js.map +0 -1
  75. package/lib/agent/tools/remove_role.js +0 -23
  76. package/lib/agent/tools/remove_role.js.map +0 -1
  77. package/lib/package.json +0 -105
  78. package/lib/src/adapter.js +0 -81
  79. package/lib/src/adapter.js.map +0 -1
  80. package/lib/src/endpoint.js +0 -600
  81. package/lib/src/endpoint.js.map +0 -1
  82. package/lib/src/gateway-config.js +0 -32
  83. package/lib/src/gateway-config.js.map +0 -1
  84. package/lib/src/group-at-normalize.js +0 -68
  85. package/lib/src/group-at-normalize.js.map +0 -1
  86. package/lib/src/inbound-action.js +0 -57
  87. package/lib/src/inbound-action.js.map +0 -1
  88. package/lib/src/inbound-normalize.js +0 -47
  89. package/lib/src/inbound-normalize.js.map +0 -1
  90. package/lib/src/index.js +0 -194
  91. package/lib/src/index.js.map +0 -1
  92. package/lib/src/mention-enrich.js +0 -52
  93. package/lib/src/mention-enrich.js.map +0 -1
  94. package/lib/src/outbound-keyboard.js +0 -100
  95. package/lib/src/outbound-keyboard.js.map +0 -1
  96. package/lib/src/outbound-markdown.js +0 -189
  97. package/lib/src/outbound-markdown.js.map +0 -1
  98. package/lib/src/outbound-media.js +0 -51
  99. package/lib/src/outbound-media.js.map +0 -1
  100. package/lib/src/outbound-mixed-media.js +0 -78
  101. package/lib/src/outbound-mixed-media.js.map +0 -1
  102. package/lib/src/platform-permit.js.map +0 -1
  103. package/lib/src/qq-agent-deps.js +0 -10
  104. package/lib/src/qq-agent-deps.js.map +0 -1
  105. package/lib/src/qq-bind-api.js.map +0 -1
  106. package/lib/src/qq-bind-flow.js.map +0 -1
  107. package/lib/src/qq-bind-persist.js.map +0 -1
  108. package/lib/src/qq-endpoint-manager.js +0 -159
  109. package/lib/src/qq-endpoint-manager.js.map +0 -1
  110. package/lib/src/sdk-version.js +0 -19
  111. package/lib/src/sdk-version.js.map +0 -1
  112. package/lib/src/segment-mapper.js +0 -2
  113. package/lib/src/segment-mapper.js.map +0 -1
  114. package/lib/src/types.js +0 -2
  115. package/lib/src/types.js.map +0 -1
  116. package/plugin.yml +0 -3
  117. package/src/adapter.ts +0 -97
  118. package/src/gateway-config.ts +0 -48
  119. package/src/group-at-normalize.ts +0 -75
  120. package/src/inbound-action.ts +0 -87
  121. package/src/inbound-normalize.ts +0 -57
  122. package/src/mention-enrich.ts +0 -71
  123. package/src/outbound-keyboard.ts +0 -117
  124. package/src/outbound-markdown.ts +0 -212
  125. package/src/outbound-media.ts +0 -58
  126. package/src/outbound-mixed-media.ts +0 -101
  127. package/src/qq-endpoint-manager.ts +0 -199
  128. package/src/sdk-version.ts +0 -21
  129. package/src/segment-mapper.ts +0 -1
  130. package/src/types.ts +0 -52
@@ -1,212 +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
- const BLOCKING_RICH_TYPES = new Set([
15
- "audio",
16
- "video",
17
- "file",
18
- "markdown",
19
- "ark",
20
- "embed",
21
- "keyboard",
22
- "button",
23
- "face",
24
- ]);
25
-
26
- const TEXT_IMAGE_MIX_TYPES = new Set(["text", "at", "image"]);
27
-
28
- function textSegment(text: string): MessageSegment {
29
- return { type: "text", data: { text } };
30
- }
31
-
32
- export function asOutboundSegments(content: SendContent): MessageSegment[] {
33
- if (typeof content === "string") return [textSegment(content)];
34
- if (!Array.isArray(content)) {
35
- return isMessageSegment(content) ? [content] : [];
36
- }
37
- return content.flatMap((item) =>
38
- typeof item === "string" ? [textSegment(item)] : isMessageSegment(item) ? [item] : [],
39
- );
40
- }
41
-
42
- export function splitReplyPrefix(segments: MessageSegment[]): {
43
- prefix: MessageSegment[];
44
- body: MessageSegment[];
45
- } {
46
- const prefix: MessageSegment[] = [];
47
- let i = 0;
48
- while (i < segments.length && segments[i].type === "reply") {
49
- prefix.push(segments[i]);
50
- i++;
51
- }
52
- return { prefix, body: segments.slice(i) };
53
- }
54
-
55
- function textFromSegment(seg: MessageSegment): string {
56
- if (seg.type !== "text") return "";
57
- return seg.data.text ?? seg.data.content ?? "";
58
- }
59
-
60
- function isRemoteUrl(value: string): boolean {
61
- return /^https?:\/\//i.test(value);
62
- }
63
-
64
- function isInlineMedia(value: string): boolean {
65
- return value.startsWith("base64://")
66
- || /^data:[^/]+\/[^;]+;base64,/i.test(value)
67
- || value.startsWith("file://")
68
- || (value.startsWith("/") && !isRemoteUrl(value));
69
- }
70
-
71
- /** QQ Markdown 图片语法:![说明 #宽 #高](公网 URL) */
72
- export function formatQqMarkdownImage(
73
- url: string,
74
- data: Record<string, unknown> = {},
75
- ): string {
76
- const width = typeof data.width === "number" && data.width > 0 ? data.width : 200;
77
- const height = typeof data.height === "number" && data.height > 0 ? data.height : width;
78
- const label = typeof data.name === "string" && data.name.trim() ? data.name.trim() : "图片";
79
- return `![${label} #${width}px #${height}px](${url})`;
80
- }
81
-
82
- /** 将 image 段解析为 Markdown 可嵌入的 URL(https 或 data URI) */
83
- export function resolveImageMarkdownUrl(data: Record<string, unknown>): string | null {
84
- const url = typeof data.url === "string" ? data.url : undefined;
85
- const file = resolveMediaFile(data);
86
-
87
- if (url && isRemoteUrl(url)) return url;
88
- if (file && isRemoteUrl(file)) return file;
89
-
90
- const inline = file ?? (url && isInlineMedia(url) ? url : undefined);
91
- if (!inline) return null;
92
-
93
- if (inline.startsWith("base64://")) {
94
- return `data:image/png;base64,${inline.slice(9)}`;
95
- }
96
- if (/^data:[^/]+\/[^;]+;base64,/i.test(inline)) {
97
- return inline;
98
- }
99
- return null;
100
- }
101
-
102
- export function isTextImageMixedBody(body: MessageSegment[]): boolean {
103
- if (body.length === 0) return false;
104
- if (body.some((seg) => !TEXT_IMAGE_MIX_TYPES.has(seg.type))) return false;
105
- const hasImage = body.some((seg) => seg.type === "image");
106
- const hasText = body.some((seg) => seg.type === "text" || seg.type === "at");
107
- return hasImage && hasText;
108
- }
109
-
110
- /** 图文混排且含 base64/本地图(无法直接嵌入 Markdown 公网 URL) */
111
- export function isTextImageMixedWithInlineMedia(body: MessageSegment[]): boolean {
112
- if (!isTextImageMixedBody(body)) return false;
113
- return body.some((seg) => {
114
- if (seg.type !== "image") return false;
115
- const url = typeof seg.data.url === "string" ? seg.data.url : undefined;
116
- const file = resolveMediaFile(seg.data);
117
- if (url && isRemoteUrl(url)) return false;
118
- if (file && isRemoteUrl(file)) return false;
119
- return Boolean(resolveMediaFile(seg.data) || (url && isInlineMedia(url)));
120
- });
121
- }
122
-
123
- function segmentToMarkdownPart(seg: MessageSegment): string {
124
- if (seg.type === "text") return textFromSegment(seg);
125
- if (seg.type === "at") return `<@${String(seg.data.user_id ?? "")}>`;
126
- if (seg.type === "image") {
127
- const src = resolveImageMarkdownUrl(seg.data);
128
- return src ? formatQqMarkdownImage(src, seg.data) : "";
129
- }
130
- return "";
131
- }
132
-
133
- function shouldConvertTextOnly(mode: OutboundMarkdownMode | undefined, bodyText: string): boolean {
134
- if (mode === false) return false;
135
- if (mode === true) return bodyText.trim().length > 0;
136
- return hasMarkdownHint(bodyText);
137
- }
138
-
139
- function hasMarkdownHint(text: string): boolean {
140
- if (text.includes('**') || text.includes('__') || text.includes('`')) return true;
141
- if (text.includes('](') || text.includes('|')) return true;
142
- return text.split('\n').some((line) => {
143
- const trimmed = line.trimStart();
144
- return trimmed.startsWith('# ') || trimmed.startsWith('## ')
145
- || trimmed.startsWith('> ')
146
- || trimmed.startsWith('- ')
147
- || trimmed.startsWith('* ')
148
- || trimmed.startsWith('+ ');
149
- });
150
- }
151
-
152
- function mergeTextImageToMarkdown(
153
- prefix: MessageSegment[],
154
- body: MessageSegment[],
155
- ): SendContent | null {
156
- if (body.some((seg) => seg.type === "image" && !resolveImageMarkdownUrl(seg.data))) {
157
- return null;
158
- }
159
- const merged = body.map(segmentToMarkdownPart).join("");
160
- if (!merged.trim()) return null;
161
- return [...prefix, segment("markdown", { content: merged })];
162
- }
163
-
164
- /**
165
- * 出站归一化:保留 leading `reply`,将连续 text 或 text+image 合并为单条 markdown 段。
166
- * 含内联 base64 图的图文混排须走 outbound-mixed-media(msg_type=7),此处不处理。
167
- */
168
- export function normalizeOutboundMarkdown(
169
- content: SendContent,
170
- mode: OutboundMarkdownMode | undefined = "auto",
171
- ): SendContent {
172
- const segments = asOutboundSegments(content);
173
- if (segments.length === 0) return content;
174
-
175
- const { prefix, body } = splitReplyPrefix(segments);
176
- if (body.length === 0) return content;
177
- if (mode === false) return content;
178
-
179
- if (body.some((seg) => BLOCKING_RICH_TYPES.has(seg.type))) {
180
- return content;
181
- }
182
-
183
- if (body.some((seg) => seg.type === "markdown")) {
184
- return content;
185
- }
186
-
187
- if (isTextImageMixedBody(body)) {
188
- if (isTextImageMixedWithInlineMedia(body)) {
189
- return content;
190
- }
191
- const mixed = mergeTextImageToMarkdown(prefix, body);
192
- return mixed ?? content;
193
- }
194
-
195
- if (body.some((seg) => seg.type === "image")) {
196
- return content;
197
- }
198
-
199
- if (body.some((seg) => seg.type !== "text" && seg.type !== "at")) {
200
- return content;
201
- }
202
-
203
- const merged = body
204
- .map((seg) => (seg.type === "at"
205
- ? `<@${String(seg.data.user_id ?? "")}>`
206
- : textFromSegment(seg)))
207
- .join("");
208
-
209
- if (!shouldConvertTextOnly(mode, merged)) return content;
210
-
211
- return [...prefix, segment("markdown", { content: merged })];
212
- }
@@ -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
- }
@@ -1,199 +0,0 @@
1
- import type {
2
- EndpointConfigRecord,
3
- EndpointManager,
4
- ProvisionContext,
5
- } from 'zhin.js';
6
- import type { QQAdapter } from './adapter.js';
7
- import { ReceiverMode as QQReceiverMode, type QQEndpointConfig, type ReceiverMode } from './types.js';
8
-
9
- import { startQqBindFlow } from './qq-bind-flow.js';
10
- import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
11
-
12
- const DEFAULT_INTENTS = [
13
- 'GUILDS',
14
- 'GUILD_MEMBERS',
15
- 'GUILD_MESSAGE_REACTIONS',
16
- 'DIRECT_MESSAGE',
17
- 'GROUP_AND_C2C_EVENT',
18
- 'INTERACTION',
19
- 'MESSAGE_AUDIT',
20
- 'AUDIO_ACTION',
21
- 'PUBLIC_GUILD_MESSAGES',
22
- ] as const;
23
-
24
- function clonePlainConfig<T>(value: T): T {
25
- return JSON.parse(JSON.stringify(value)) as T;
26
- }
27
-
28
- function resolveQqEndpointTemplate(root: ProvisionContext['root']): Partial<QQEndpointConfig<ReceiverMode>> {
29
- const configService = root.inject('config') as {
30
- getRaw?: (f: string) => { endpoints?: Array<Record<string, unknown>> };
31
- primaryFile?: string;
32
- } | undefined;
33
- const raw = configService?.primaryFile && configService.getRaw
34
- ? configService.getRaw(configService.primaryFile)
35
- : undefined;
36
- const existing = raw?.endpoints?.find((e) => e.context === 'qq');
37
- if (!existing) {
38
- return {
39
- mode: QQReceiverMode.WEBSOCKET,
40
- sandbox: false,
41
- timeout: 10_000,
42
- data_dir: './data',
43
- intents: [...DEFAULT_INTENTS],
44
- };
45
- }
46
- const { name: _n, appid: _a, secret: _s, context: _c, ...rest } = existing;
47
- return clonePlainConfig(rest) as Partial<QQEndpointConfig<ReceiverMode>>;
48
- }
49
-
50
- function resolveBindOperatorId(
51
- userOpenId: string | undefined,
52
- ctx: ProvisionContext,
53
- ): string {
54
- const fromApi = userOpenId?.trim();
55
- if (fromApi) return fromApi;
56
- return ctx.message.$sender?.id?.trim() ?? '';
57
- }
58
-
59
- function applyBindOwnership(
60
- template: Partial<QQEndpointConfig<ReceiverMode>>,
61
- operatorId: string,
62
- ): Partial<QQEndpointConfig<ReceiverMode>> {
63
- const { master: _m, aiAccess, ...rest } = template as Record<string, unknown>;
64
- if (!operatorId) {
65
- return rest as Partial<QQEndpointConfig<ReceiverMode>>;
66
- }
67
- const prior = aiAccess as {
68
- mode?: string;
69
- groups?: string[];
70
- denyMessage?: string;
71
- } | undefined;
72
- return {
73
- ...rest,
74
- master: operatorId,
75
- aiAccess: {
76
- mode: prior?.mode ?? 'whitelist',
77
- users: [operatorId],
78
- groups: Array.isArray(prior?.groups) ? [...prior.groups] : [],
79
- denyMessage:
80
- typeof prior?.denyMessage === 'string'
81
- ? prior.denyMessage
82
- : '当前用户尚未开放 AI 功能,请联系开启。',
83
- },
84
- } as Partial<QQEndpointConfig<ReceiverMode>>;
85
- }
86
-
87
- function readQqConfigs(root: ProvisionContext['root']): EndpointConfigRecord[] {
88
- const configService = root.inject('config') as { getRaw?: (f: string) => { endpoints?: EndpointConfigRecord[] } ; primaryFile?: string } | undefined;
89
- if (!configService?.primaryFile || !configService.getRaw) return [];
90
- const raw = configService.getRaw(configService.primaryFile);
91
- return (raw?.endpoints ?? []).filter((e) => e.context === 'qq');
92
- }
93
-
94
- /** 全局单例:同一时间只允许一个 QQ 扫码绑定 */
95
- let activeStopFlow: (() => void) | null = null;
96
-
97
- export class QqEndpointManager implements EndpointManager {
98
- constructor(private readonly adapter: QQAdapter) {}
99
-
100
- supportsProvision(): boolean {
101
- return true;
102
- }
103
-
104
- listEndpoints(): EndpointConfigRecord[] {
105
- return readQqConfigs(this.adapter.plugin.root);
106
- }
107
-
108
- cancelProvision(): boolean {
109
- if (!activeStopFlow) return false;
110
- activeStopFlow();
111
- activeStopFlow = null;
112
- return true;
113
- }
114
-
115
- async addEndpoint(ctx: ProvisionContext): Promise<EndpointConfigRecord> {
116
- if (activeStopFlow) {
117
- throw new Error('已有进行中的 QQ 机器人绑定,请先 /endpoint cancel');
118
- }
119
-
120
- let endpointName: string | undefined;
121
- const nameMatch = ctx.message.$raw?.match(/\/endpoint\s+add\s+qq\s+(\S+)/);
122
- if (nameMatch?.[1]) {
123
- endpointName = nameMatch[1];
124
- }
125
-
126
- return new Promise<EndpointConfigRecord>((resolve, reject) => {
127
- const stopFlow = startQqBindFlow(
128
- {
129
- onQrDisplayed: async (url) => {
130
- await ctx.onStatusUpdate('请用手机 QQ 扫描二维码完成机器人绑定。', {
131
- qrcode: url,
132
- });
133
- },
134
- onQrExpired: async () => {
135
- await ctx.onStatusUpdate('二维码已过期,正在刷新,请扫描新二维码…');
136
- },
137
- onSuccess: async (credentials) => {
138
- activeStopFlow = null;
139
- const [{ appId, appSecret, userOpenId }] = credentials;
140
- const name = endpointName?.trim() || appId;
141
- await ctx.onStatusUpdate(
142
- '凭据绑定成功,正在写入配置…',
143
- );
144
- const envKeys = persistQqCredentialsToEnv(name, appId, appSecret);
145
- const template = resolveQqEndpointTemplate(ctx.root);
146
- const operatorId = resolveBindOperatorId(userOpenId, ctx);
147
- const owned = applyBindOwnership(template, operatorId);
148
- resolve({
149
- context: 'qq',
150
- name,
151
- appid: envKeys.appidRef,
152
- secret: envKeys.secretRef,
153
- ...owned,
154
- } as EndpointConfigRecord);
155
- },
156
- onFailure: async (error) => {
157
- activeStopFlow = null;
158
- reject(error);
159
- },
160
- },
161
- { source: 'zhin' },
162
- );
163
- activeStopFlow = stopFlow;
164
- });
165
- }
166
-
167
- async editEndpoint(name: string, ctx: ProvisionContext): Promise<EndpointConfigRecord> {
168
- const existing = this.listEndpoints().find((e) => e.name === name);
169
- if (!existing) {
170
- throw new Error(`配置中不存在 qq/${name}`);
171
- }
172
- await ctx.onStatusUpdate(
173
- `QQ 官方机器人暂不支持在线改凭据,请 /endpoint remove qq ${name} 后重新 /endpoint add qq。`,
174
- );
175
- return existing;
176
- }
177
-
178
- async removeEndpoint(name: string): Promise<boolean> {
179
- return this.listEndpoints().some((e) => e.name === name);
180
- }
181
-
182
- async startEndpoint(name: string, ctx: ProvisionContext): Promise<void> {
183
- if (!this.listEndpoints().some((e) => e.name === name)) {
184
- throw new Error(`配置中不存在 qq/${name}`);
185
- }
186
- await ctx.onStatusUpdate(`正在连接 qq/${name}…`);
187
- }
188
-
189
- async stopEndpoint(name: string): Promise<boolean> {
190
- return this.adapter.endpoints.has(name);
191
- }
192
- }
193
-
194
- export function disposeQqEndpointProvision(): void {
195
- if (activeStopFlow) {
196
- activeStopFlow();
197
- activeStopFlow = null;
198
- }
199
- }
@@ -1,21 +0,0 @@
1
- import { createRequire } from "node:module";
2
- import adapterPkg from "../package.json" with { type: "json" };
3
-
4
- const require = createRequire(import.meta.url);
5
-
6
- function readDepVersion(dep: string): string {
7
- try {
8
- return (require(`${dep}/package.json`) as { version: string }).version;
9
- } catch {
10
- return "unknown";
11
- }
12
- }
13
-
14
- /** HTTP header name for outbound QQ API requests. */
15
- export const SDK_VERSION_HEADER = "x-sdk-version";
16
-
17
- /**
18
- * Composite SDK identity: Zhin adapter + underlying qq-official-endpoint.
19
- * Example: `zhin-adapter-qq/v2.0.11+qq-official-bot/v1.2.1`
20
- */
21
- export const SDK_VERSION = `zhin-adapter-qq/v${adapterPkg.version}+qq-official-bot/v${readDepVersion("qq-official-bot")}`;
@@ -1 +0,0 @@
1
- export { toCanonicalSegments, fromCanonicalSegments } from 'zhin.js';
package/src/types.ts DELETED
@@ -1,52 +0,0 @@
1
- /**
2
- * QQ 官方机器人适配器类型定义
3
- */
4
- import { type Bot as QQOfficialBot, type ReceiverMode, type ApplicationPlatform } from 'qq-official-bot';
5
-
6
- export { ReceiverMode } from "qq-official-bot";
7
- export type { ApplicationPlatform, Intent } from "qq-official-bot";
8
-
9
- export type QQEndpointConfig<
10
- T extends ReceiverMode,
11
- M extends ApplicationPlatform = ApplicationPlatform,
12
- > = QQOfficialBot.Config<T, M> & {
13
- context: "qq";
14
- name: string;
15
- data_dir?: string;
16
- /**
17
- * middleware 模式:挂在 host-router 的回调路径(默认 `/qq/webhook`,完整 URL 为 `{host}:8086/qq/webhook`,无 `/api` 前缀)。
18
- * webhook 独立端口模式:使用 qq-official-bot 的 `path` 字段。
19
- */
20
- webhookPath?: string;
21
- /**
22
- * 自定义 access token 接口(完整 URL)。
23
- * 默认 `https://bots.qq.com/app/getAppAccessToken`;代理或私有部署时可覆盖。
24
- */
25
- accessTokenUrl?: string;
26
- /**
27
- * 自定义 gateway 接口(完整 URL 或相对路径,如 `/gateway/bot`)。
28
- * 响应中的 `url` 为 WebSocket 地址;**仅 `mode: websocket` 入站时使用**。
29
- */
30
- gatewayUrl?: string;
31
- /**
32
- * AI 出站是否转为 QQ Markdown(`msg_type=2`)。
33
- * - `auto`(默认):正文含 Markdown 语法时转换
34
- * - `true`:纯文本也走 Markdown
35
- * - `false`:保持纯文本
36
- */
37
- outboundMarkdown?: boolean | "auto";
38
- /** 本 Endpoint AI 访问控制(见 docs/advanced/content-moderation.md) */
39
- aiAccess?: {
40
- mode?: 'open' | 'closed' | 'whitelist';
41
- users?: string[];
42
- groups?: string[];
43
- denyMessage?: string;
44
- };
45
- };
46
-
47
- export interface QQEndpoint<
48
- T extends ReceiverMode,
49
- M extends ApplicationPlatform = ApplicationPlatform,
50
- > {
51
- $config: QQEndpointConfig<T, M>;
52
- }