@zhin.js/adapter-qq 5.0.0 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +40 -1
  3. package/dist/index.js +2 -2
  4. package/lib/adapter.d.ts +10 -3
  5. package/lib/adapter.d.ts.map +1 -1
  6. package/lib/adapter.js +12 -4
  7. package/lib/adapter.js.map +1 -1
  8. package/lib/endpoint.d.ts +5 -0
  9. package/lib/endpoint.d.ts.map +1 -1
  10. package/lib/endpoint.js +79 -2
  11. package/lib/endpoint.js.map +1 -1
  12. package/lib/group-at-normalize.d.ts.map +1 -1
  13. package/lib/group-at-normalize.js +2 -5
  14. package/lib/group-at-normalize.js.map +1 -1
  15. package/lib/inbound-action.d.ts +15 -0
  16. package/lib/inbound-action.d.ts.map +1 -0
  17. package/lib/inbound-action.js +57 -0
  18. package/lib/inbound-action.js.map +1 -0
  19. package/lib/index.js +3 -3
  20. package/lib/mention-enrich.d.ts +12 -0
  21. package/lib/mention-enrich.d.ts.map +1 -0
  22. package/lib/mention-enrich.js +52 -0
  23. package/lib/mention-enrich.js.map +1 -0
  24. package/lib/outbound-keyboard.d.ts +4 -0
  25. package/lib/outbound-keyboard.d.ts.map +1 -0
  26. package/lib/outbound-keyboard.js +96 -0
  27. package/lib/outbound-keyboard.js.map +1 -0
  28. package/lib/outbound-markdown.d.ts +16 -4
  29. package/lib/outbound-markdown.d.ts.map +1 -1
  30. package/lib/outbound-markdown.js +116 -17
  31. package/lib/outbound-markdown.js.map +1 -1
  32. package/lib/outbound-media.d.ts +2 -0
  33. package/lib/outbound-media.d.ts.map +1 -1
  34. package/lib/outbound-media.js +2 -1
  35. package/lib/outbound-media.js.map +1 -1
  36. package/lib/outbound-mixed-media.d.ts +21 -0
  37. package/lib/outbound-mixed-media.d.ts.map +1 -0
  38. package/lib/outbound-mixed-media.js +78 -0
  39. package/lib/outbound-mixed-media.js.map +1 -0
  40. package/lib/platform-permit.js +2 -2
  41. package/lib/qq-bind-api.d.ts +10 -0
  42. package/lib/qq-bind-api.d.ts.map +1 -1
  43. package/lib/qq-bind-api.js +9 -5
  44. package/lib/qq-bind-api.js.map +1 -1
  45. package/lib/qq-bind-flow.d.ts +1 -0
  46. package/lib/qq-bind-flow.d.ts.map +1 -1
  47. package/lib/qq-bind-flow.js +11 -2
  48. package/lib/qq-bind-flow.js.map +1 -1
  49. package/lib/qq-bind-persist.d.ts +11 -0
  50. package/lib/qq-bind-persist.d.ts.map +1 -0
  51. package/lib/qq-bind-persist.js +52 -0
  52. package/lib/qq-bind-persist.js.map +1 -0
  53. package/lib/qq-endpoint-manager.d.ts.map +1 -1
  54. package/lib/qq-endpoint-manager.js +43 -8
  55. package/lib/qq-endpoint-manager.js.map +1 -1
  56. package/lib/segment-mapper.d.ts +2 -0
  57. package/lib/segment-mapper.d.ts.map +1 -0
  58. package/lib/segment-mapper.js +2 -0
  59. package/lib/segment-mapper.js.map +1 -0
  60. package/lib/types.d.ts +7 -0
  61. package/lib/types.d.ts.map +1 -1
  62. package/package.json +16 -16
  63. package/skills/qq/PERMITS.md +1 -1
  64. package/src/adapter.ts +12 -4
  65. package/src/endpoint.ts +119 -6
  66. package/src/group-at-normalize.ts +2 -4
  67. package/src/inbound-action.ts +87 -0
  68. package/src/index.ts +4 -4
  69. package/src/mention-enrich.ts +71 -0
  70. package/src/outbound-keyboard.ts +118 -0
  71. package/src/outbound-markdown.ts +127 -17
  72. package/src/outbound-media.ts +2 -1
  73. package/src/outbound-mixed-media.ts +101 -0
  74. package/src/platform-permit.ts +2 -2
  75. package/src/qq-bind-api.ts +24 -5
  76. package/src/qq-bind-flow.ts +13 -3
  77. package/src/qq-bind-persist.ts +71 -0
  78. package/src/qq-endpoint-manager.ts +62 -9
  79. package/src/segment-mapper.ts +1 -0
  80. package/src/types.ts +7 -0
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * 将 AI 出站纯文本段合并为 QQ Markdown 消息(msg_type=2)。
3
- * 官方已开放全量 Markdown,避免 **bold** 等语法以纯文本展示。
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 BODY_RICH_TYPES = new Set([
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 asSegments(content: SendContent): MessageSegment[] {
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 shouldConvert(mode: OutboundMarkdownMode | undefined, bodyText: string): boolean {
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 图片语法:![说明 #宽 #高](公网 URL) */
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 `![${label} #${width}px #${height}px](${url})`;
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 = asSegments(content);
163
+ const segments = asOutboundSegments(content);
64
164
  if (segments.length === 0) return content;
65
165
 
66
- const prefix: MessageSegment[] = [];
67
- let i = 0;
68
- while (i < segments.length && segments[i].type === "reply") {
69
- prefix.push(segments[i]);
70
- i++;
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
- const body = segments.slice(i);
74
- if (body.length === 0) return content;
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) => BODY_RICH_TYPES.has(seg.type))) {
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 (!shouldConvert(mode, merged)) return content;
200
+ if (!shouldConvertTextOnly(mode, merged)) return content;
91
201
 
92
202
  return [...prefix, segment("markdown", { content: merged })];
93
203
  }
@@ -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
- function resolveMediaFile(data: Record<string, unknown>): string | undefined {
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
+ }
@@ -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
- group_admin: 'guild_admin',
16
- group_owner: 'guild_owner',
15
+ scene_admin: 'guild_admin',
16
+ scene_owner: 'guild_owner',
17
17
  };
18
18
 
19
19
  export function qqGuildPermitResolver(logicalPerm: string): string {
@@ -102,6 +102,26 @@ export interface PollBindResultOk {
102
102
  status: BindStatus;
103
103
  botAppId: string;
104
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
+ };
105
125
  }
106
126
 
107
127
  export async function pollBindResult(
@@ -114,13 +134,12 @@ export async function pollBindResult(
114
134
  status?: number;
115
135
  bot_appid?: string | number;
116
136
  bot_encrypt_secret?: string;
137
+ user_openid?: string;
138
+ userOpenid?: string;
139
+ openid?: string;
117
140
  }>(url, { task_id: taskId }, timeoutMs);
118
141
  if (res.retcode !== 0) {
119
142
  throw new Error(res.msg ?? 'poll_bind_result failed');
120
143
  }
121
- return {
122
- status: (res.data?.status ?? BindStatus.NONE) as BindStatus,
123
- botAppId: String(res.data?.bot_appid ?? ''),
124
- botEncryptSecret: res.data?.bot_encrypt_secret ?? '',
125
- };
144
+ return parsePollBindResultData(res.data);
126
145
  }
@@ -14,6 +14,7 @@ const POLL_INTERVAL_MS = 2000;
14
14
  export interface QqBindCredentials {
15
15
  appId: string;
16
16
  appSecret: string;
17
+ userOpenId?: string;
17
18
  }
18
19
 
19
20
  export interface QqBindCallbacks {
@@ -50,7 +51,7 @@ async function pollUntilResult(
50
51
  taskId: string,
51
52
  key: string,
52
53
  signal?: AbortSignal,
53
- ): Promise<{ outcome: 'scanned'; appId: string; appSecret: string } | { outcome: 'expired' }> {
54
+ ): Promise<{ outcome: 'scanned'; appId: string; appSecret: string; userOpenId: string } | { outcome: 'expired' }> {
54
55
  while (!signal?.aborted) {
55
56
  let result;
56
57
  try {
@@ -61,7 +62,12 @@ async function pollUntilResult(
61
62
  }
62
63
  if (result.status === BindStatus.COMPLETED) {
63
64
  const appSecret = decryptSecret(result.botEncryptSecret, key);
64
- return { outcome: 'scanned', appId: result.botAppId, appSecret };
65
+ return {
66
+ outcome: 'scanned',
67
+ appId: result.botAppId,
68
+ appSecret,
69
+ userOpenId: result.userOpenId,
70
+ };
65
71
  }
66
72
  if (result.status === BindStatus.EXPIRED) {
67
73
  return { outcome: 'expired' };
@@ -101,7 +107,11 @@ export function startQqBindFlow(
101
107
  await callbacks.onQrDisplayed?.(connectUrl);
102
108
  const pollResult = await pollUntilResult(task.taskId, task.key, signal);
103
109
  if (pollResult.outcome === 'scanned') {
104
- callbacks.onSuccess([{ appId: pollResult.appId, appSecret: pollResult.appSecret }]);
110
+ callbacks.onSuccess([{
111
+ appId: pollResult.appId,
112
+ appSecret: pollResult.appSecret,
113
+ userOpenId: pollResult.userOpenId || undefined,
114
+ }]);
105
115
  return;
106
116
  }
107
117
  await callbacks.onQrExpired?.();
@@ -0,0 +1,71 @@
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
+ }
@@ -7,6 +7,7 @@ import type { QQAdapter } from './adapter.js';
7
7
  import type { QQEndpointConfig, ReceiverMode } from './types.js';
8
8
  import { ReceiverMode as QQReceiverMode } from './types.js';
9
9
  import { startQqBindFlow } from './qq-bind-flow.js';
10
+ import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
10
11
 
11
12
  const DEFAULT_INTENTS = [
12
13
  'GUILDS',
@@ -20,10 +21,19 @@ const DEFAULT_INTENTS = [
20
21
  'PUBLIC_GUILD_MESSAGES',
21
22
  ] as const;
22
23
 
24
+ function clonePlainConfig<T>(value: T): T {
25
+ return JSON.parse(JSON.stringify(value)) as T;
26
+ }
27
+
23
28
  function resolveQqEndpointTemplate(root: ProvisionContext['root']): Partial<QQEndpointConfig<ReceiverMode>> {
24
- const configService = root.inject('config') as { getPrimary?: () => { endpoints?: Array<Record<string, unknown>> } } | undefined;
25
- const doc = configService?.getPrimary?.();
26
- const existing = doc?.endpoints?.find((e) => e.context === 'qq');
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');
27
37
  if (!existing) {
28
38
  return {
29
39
  mode: QQReceiverMode.WEBSOCKET,
@@ -34,7 +44,44 @@ function resolveQqEndpointTemplate(root: ProvisionContext['root']): Partial<QQEn
34
44
  };
35
45
  }
36
46
  const { name: _n, appid: _a, secret: _s, context: _c, ...rest } = existing;
37
- return rest as Partial<QQEndpointConfig<ReceiverMode>>;
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>>;
38
85
  }
39
86
 
40
87
  function readQqConfigs(root: ProvisionContext['root']): EndpointConfigRecord[] {
@@ -80,7 +127,7 @@ export class QqEndpointManager implements EndpointManager {
80
127
  const stopFlow = startQqBindFlow(
81
128
  {
82
129
  onQrDisplayed: async (url) => {
83
- await ctx.onStatusUpdate('请用手机 QQ 扫描下方二维码完成机器人绑定(source=zhin)。', {
130
+ await ctx.onStatusUpdate('请用手机 QQ 扫描二维码完成机器人绑定。', {
84
131
  qrcode: url,
85
132
  });
86
133
  },
@@ -89,15 +136,21 @@ export class QqEndpointManager implements EndpointManager {
89
136
  },
90
137
  onSuccess: async (credentials) => {
91
138
  activeStopFlow = null;
92
- const [{ appId, appSecret }] = credentials;
139
+ const [{ appId, appSecret, userOpenId }] = credentials;
93
140
  const name = endpointName?.trim() || appId;
141
+ await ctx.onStatusUpdate(
142
+ '凭据绑定成功,正在写入配置…',
143
+ );
144
+ const envKeys = persistQqCredentialsToEnv(name, appId, appSecret);
94
145
  const template = resolveQqEndpointTemplate(ctx.root);
146
+ const operatorId = resolveBindOperatorId(userOpenId, ctx);
147
+ const owned = applyBindOwnership(template, operatorId);
95
148
  resolve({
96
149
  context: 'qq',
97
150
  name,
98
- appid: appId,
99
- secret: appSecret,
100
- ...template,
151
+ appid: envKeys.appidRef,
152
+ secret: envKeys.secretRef,
153
+ ...owned,
101
154
  } as EndpointConfigRecord);
102
155
  },
103
156
  onFailure: async (error) => {
@@ -0,0 +1 @@
1
+ export { toCanonicalSegments, fromCanonicalSegments } from 'zhin.js';
package/src/types.ts CHANGED
@@ -36,6 +36,13 @@ export type QQEndpointConfig<
36
36
  * - `false`:保持纯文本
37
37
  */
38
38
  outboundMarkdown?: boolean | "auto";
39
+ /** 本 Endpoint AI 访问控制(见 docs/advanced/content-moderation.md) */
40
+ aiAccess?: {
41
+ mode?: 'open' | 'closed' | 'whitelist';
42
+ users?: string[];
43
+ groups?: string[];
44
+ denyMessage?: string;
45
+ };
39
46
  };
40
47
 
41
48
  export interface QQEndpoint<