@zhin.js/adapter-qq 6.0.0 → 6.0.2

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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * QQ 官方出站富媒体归一化:把渲染后的 payload 转为 qq-official-bot 可识别的
3
+ * Sendable 结构(文本串 + 消息段)。移植自 legacy 版 outbound-media /
4
+ * outbound-markdown / outbound-keyboard。
5
+ *
6
+ * 移植面:
7
+ * - image:http(s) URL 直发(data.url);base64://、data:...;base64、本地路径
8
+ * 归一到 data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
9
+ * - markdown:data.content 原文,或 custom_template_id + params 模板。
10
+ * - keyboard:data.rows 按钮矩阵展开为 button 段(SDK processButtons 组装
11
+ * keyboard 载荷);按钮消息必须带 markdown,故同行文本合并为 markdown 段。
12
+ * data.id 模板键盘原样透传。
13
+ * 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
14
+ */
15
+ import { formatCompact, getLogger } from '@zhin.js/logger';
16
+ import type { QqWireSegment } from './protocol.js';
17
+
18
+ const logger = getLogger('qq');
19
+
20
+ /** qq-official-bot Sendable 的最小结构子集(字符串模板或消息段)。 */
21
+ export interface QqOutboundElem {
22
+ readonly type: string;
23
+ readonly data: Record<string, unknown>;
24
+ }
25
+
26
+ export type QqOutboundMessage = string | QqOutboundElem | readonly (string | QqOutboundElem)[];
27
+
28
+ interface QqOutboundButtonSpec {
29
+ readonly id?: string;
30
+ readonly label?: string;
31
+ readonly payload?: string;
32
+ readonly disabled?: boolean;
33
+ readonly mode?: string;
34
+ readonly command?: { readonly enter?: boolean; readonly reply?: boolean };
35
+ }
36
+
37
+ function isRemoteUrl(value: string): boolean {
38
+ return /^https?:\/\//i.test(value);
39
+ }
40
+
41
+ function isInlineBase64(value: string): boolean {
42
+ return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
43
+ }
44
+
45
+ /**
46
+ * 解析 image/audio/video/file 段的本地或内联 payload。
47
+ * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL;
48
+ * base64:// / data:...;base64 / 本地路径须走 data.file 才会解析为 file_data。
49
+ */
50
+ export function resolveMediaFile(data: Record<string, unknown>): string | undefined {
51
+ const url = typeof data.url === 'string' ? data.url : undefined;
52
+ const file = typeof data.file === 'string' ? data.file : undefined;
53
+ const base64 = typeof data.base64 === 'string' ? data.base64 : undefined;
54
+
55
+ if (file && !isRemoteUrl(file)) return file;
56
+ if (url && isInlineBase64(url)) return url;
57
+ if (url && !isRemoteUrl(url) && (url.startsWith('file://') || url.startsWith('/'))) return url;
58
+ if (base64) return base64.startsWith('base64://') ? base64 : `base64://${base64}`;
59
+ return undefined;
60
+ }
61
+
62
+ function normalizeMediaSegment(seg: QqWireSegment): QqOutboundElem | null {
63
+ const data = { ...(seg.data ?? {}) };
64
+ const file = resolveMediaFile(data);
65
+ if (file) {
66
+ delete data.url;
67
+ delete data.base64;
68
+ data.file = file;
69
+ return { type: seg.type, data };
70
+ }
71
+ const url = typeof data.url === 'string' ? data.url
72
+ : typeof data.file === 'string' ? data.file
73
+ : undefined;
74
+ if (url && isRemoteUrl(url)) {
75
+ delete data.base64;
76
+ return { type: seg.type, data: { ...data, url } };
77
+ }
78
+ // 既无远程 URL 也无本地上传源:无法投递,降级留痕
79
+ logger.warn(formatCompact({
80
+ op: 'qq_outbound_media_dropped',
81
+ type: seg.type,
82
+ reason: 'missing_source',
83
+ }));
84
+ return null;
85
+ }
86
+
87
+ function normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
88
+ const data = seg.data ?? {};
89
+ const templateId = typeof data.custom_template_id === 'string' && data.custom_template_id
90
+ ? data.custom_template_id
91
+ : typeof data.template_id === 'string' && data.template_id
92
+ ? data.template_id
93
+ : undefined;
94
+ if (templateId) {
95
+ return {
96
+ type: 'markdown',
97
+ data: {
98
+ custom_template_id: templateId,
99
+ params: Array.isArray(data.params) ? data.params : [],
100
+ },
101
+ };
102
+ }
103
+ const content = String(data.content ?? data.text ?? '');
104
+ if (!content.trim()) {
105
+ logger.warn(formatCompact({ op: 'qq_outbound_markdown_dropped', reason: 'empty_content' }));
106
+ return null;
107
+ }
108
+ return { type: 'markdown', data: { content } };
109
+ }
110
+
111
+ function coreButtonToQq(btn: QqOutboundButtonSpec): Record<string, unknown> {
112
+ const isCommand = btn.mode === 'command';
113
+ const action: Record<string, unknown> = {
114
+ type: isCommand ? 2 : 1,
115
+ permission: { type: 2 },
116
+ data: btn.payload ?? '',
117
+ click_limit: btn.disabled ? 0 : 10,
118
+ unsupport_tips: btn.disabled ? '该按钮不可用' : '',
119
+ };
120
+ if (isCommand) {
121
+ if (btn.command?.enter != null) action.enter = btn.command.enter;
122
+ if (btn.command?.reply != null) action.reply = btn.command.reply;
123
+ }
124
+ return {
125
+ id: btn.id ?? '',
126
+ render_data: {
127
+ label: btn.label ?? '',
128
+ visited_label: btn.label ?? '',
129
+ style: 0,
130
+ },
131
+ action,
132
+ };
133
+ }
134
+
135
+ /** keyboard 段 → 模板键盘(data.id)或 button 行段(data.rows) */
136
+ function expandKeyboardSegment(seg: QqWireSegment): QqOutboundElem[] {
137
+ const data = seg.data ?? {};
138
+ if (typeof data.id === 'string' && data.id) {
139
+ return [{ type: 'keyboard', data: { id: data.id } }];
140
+ }
141
+ if (!Array.isArray(data.rows)) {
142
+ logger.warn(formatCompact({ op: 'qq_outbound_keyboard_dropped', reason: 'missing_rows' }));
143
+ return [];
144
+ }
145
+ const out: QqOutboundElem[] = [];
146
+ for (const row of data.rows) {
147
+ if (!Array.isArray(row)) continue;
148
+ out.push({
149
+ type: 'button',
150
+ data: {
151
+ buttons: row.map((btn) => coreButtonToQq((btn ?? {}) as QqOutboundButtonSpec)),
152
+ },
153
+ });
154
+ }
155
+ return out;
156
+ }
157
+
158
+ function toSegmentList(payload: unknown): Array<string | QqWireSegment> {
159
+ if (Array.isArray(payload)) return payload as Array<string | QqWireSegment>;
160
+ if (payload && typeof payload === 'object' && 'type' in payload) {
161
+ return [payload as QqWireSegment];
162
+ }
163
+ return [];
164
+ }
165
+
166
+ function textFromUnknownSegment(seg: QqWireSegment): string {
167
+ const data = seg.data ?? {};
168
+ return data.text != null ? String(data.text) : '';
169
+ }
170
+
171
+ /**
172
+ * 渲染后 payload → QQ Sendable。
173
+ * 纯文本输入原样返回(与既有行为一致);含富媒体段时返回段数组。
174
+ */
175
+ export function formatOutbound(payload: unknown): QqOutboundMessage {
176
+ if (typeof payload === 'string') return payload;
177
+
178
+ const segments = toSegmentList(payload);
179
+ if (segments.length === 0) {
180
+ return payload == null
181
+ ? ''
182
+ : typeof payload === 'object'
183
+ ? JSON.stringify(payload)
184
+ : String(payload);
185
+ }
186
+
187
+ // QQ 按钮消息必须携带 markdown(msg_type=2),keyboard 存在时文本合并为 markdown 段
188
+ const hasKeyboard = segments.some(
189
+ (item) => typeof item !== 'string' && item.type === 'keyboard',
190
+ );
191
+
192
+ const parts: Array<string | QqOutboundElem> = [];
193
+ let textBuf = '';
194
+ const flushText = (): void => {
195
+ if (!textBuf) return;
196
+ parts.push(hasKeyboard
197
+ ? { type: 'markdown', data: { content: textBuf } }
198
+ : textBuf);
199
+ textBuf = '';
200
+ };
201
+
202
+ for (const item of segments) {
203
+ if (typeof item === 'string') {
204
+ textBuf += item;
205
+ continue;
206
+ }
207
+ const data = item.data ?? {};
208
+ switch (item.type) {
209
+ case 'text':
210
+ textBuf += String(data.text ?? data.content ?? '');
211
+ break;
212
+ case 'at': {
213
+ const id = String(data.user_id ?? data.qq ?? data.id ?? '');
214
+ if (!id) break;
215
+ flushText();
216
+ parts.push({ type: 'at', data: { user_id: id } });
217
+ break;
218
+ }
219
+ case 'image':
220
+ case 'audio':
221
+ case 'video':
222
+ case 'file': {
223
+ flushText();
224
+ const media = normalizeMediaSegment(item);
225
+ if (media) parts.push(media);
226
+ break;
227
+ }
228
+ case 'markdown': {
229
+ flushText();
230
+ const markdown = normalizeMarkdownSegment(item);
231
+ if (markdown) parts.push(markdown);
232
+ break;
233
+ }
234
+ case 'keyboard': {
235
+ flushText();
236
+ parts.push(...expandKeyboardSegment(item));
237
+ break;
238
+ }
239
+ case 'reply': {
240
+ const id = data.id ?? data.event_id;
241
+ if (id == null || id === '') break;
242
+ flushText();
243
+ parts.push({
244
+ type: 'reply',
245
+ data: data.event_id != null
246
+ ? { event_id: String(data.event_id) }
247
+ : { id: String(id) },
248
+ });
249
+ break;
250
+ }
251
+ default:
252
+ // 未识别段:保持既有文本降级
253
+ textBuf += textFromUnknownSegment(item);
254
+ break;
255
+ }
256
+ }
257
+ flushText();
258
+
259
+ if (parts.length === 0) return '';
260
+ if (parts.length === 1) return parts[0]!;
261
+ return parts;
262
+ }
package/src/protocol.ts CHANGED
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import { pickCredential } from '@zhin.js/adapter';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+
9
+ const logger = getLogger('qq');
7
10
 
8
11
  /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
9
12
  export interface QqAdapterConfig {
@@ -193,8 +196,19 @@ export function formatOutboundText(payload: unknown): string {
193
196
  const id = String(data.user_id ?? data.qq ?? data.id ?? '');
194
197
  return id ? `<@!${id}>` : '';
195
198
  }
196
- case 'image':
197
- return String(data.url || data.file || '');
199
+ case 'image': {
200
+ const src = String(data.url || data.file || '');
201
+ // base64/本地路径由 formatOutbound 走媒体上传,文本视图静默省略;
202
+ // 仅当彻底缺少来源时留痕,避免出站静默降级排障无头绪。
203
+ if (!src) {
204
+ logger.warn(formatCompact({
205
+ op: 'qq_outbound_image_dropped',
206
+ reason: 'missing_source',
207
+ }));
208
+ return '';
209
+ }
210
+ return /^https?:\/\//.test(src) ? src : '';
211
+ }
198
212
  case 'reply':
199
213
  return '';
200
214
  default:
@@ -1,5 +1,10 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
1
+ import {
2
+ buildEndpointEnvKey,
3
+ persistEndpointEnvValues,
4
+ resolveProjectRoot,
5
+ } from '@zhin.js/adapter';
6
+
7
+ export { resolveProjectRoot };
3
8
 
4
9
  export interface QqCredentialEnvKeys {
5
10
  appidKey: string;
@@ -10,9 +15,8 @@ export interface QqCredentialEnvKeys {
10
15
 
11
16
  /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
12
17
  export function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvKeys {
13
- const slug = endpointName.replace(/[^a-zA-Z0-9_]/g, '_').toUpperCase();
14
- const appidKey = `QQ_${slug}_APPID`;
15
- const secretKey = `QQ_${slug}_SECRET`;
18
+ const appidKey = buildEndpointEnvKey('qq', endpointName, 'appid');
19
+ const secretKey = buildEndpointEnvKey('qq', endpointName, 'secret');
16
20
  return {
17
21
  appidKey,
18
22
  secretKey,
@@ -21,29 +25,6 @@ export function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvK
21
25
  };
22
26
  }
23
27
 
24
- /** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
25
- export function resolveProjectRoot(): string {
26
- const envRoot = process.env.ZHIN_PROJECT_ROOT?.trim();
27
- return path.resolve(envRoot || process.cwd());
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
28
  /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
48
29
  export function persistQqCredentialsToEnv(
49
30
  endpointName: string,
@@ -52,20 +33,12 @@ export function persistQqCredentialsToEnv(
52
33
  projectRoot?: string,
53
34
  ): QqCredentialEnvKeys {
54
35
  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);
36
+ persistEndpointEnvValues(
37
+ {
38
+ [keys.appidKey]: appId,
39
+ [keys.secretKey]: appSecret,
40
+ },
41
+ projectRoot,
42
+ );
70
43
  return keys;
71
44
  }
@@ -1,16 +1,27 @@
1
1
  /**
2
2
  * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
3
+ * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
4
+ * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
5
+ * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
6
+ * qqEndpointCommands 供 commands/endpoint/ 下的命令文件默认导出。
3
7
  */
8
+ import {
9
+ createEndpointCommands,
10
+ endpointCommandForbidden,
11
+ extractEndpointCommandReply,
12
+ formatEndpointList,
13
+ isEndpointOperator,
14
+ removeEndpointByName,
15
+ type ConfiguredEndpointEntry,
16
+ type EndpointCommandReply,
17
+ } from '@zhin.js/adapter';
18
+ import { defineCommand } from '@zhin.js/command';
4
19
  import { startQqBindFlow } from './qq-bind-flow.js';
5
20
  import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
6
- import {
7
- addQqEndpointToConfig,
8
- listQqEndpointEntries,
9
- removeQqEndpointFromConfig,
10
- } from './qq-endpoint-config.js';
11
- import type { QqRuntimeState } from './qq-runtime-state.js';
21
+ import { addQqEndpointToConfig, listQqEndpointEntries } from './qq-endpoint-config.js';
22
+ import { qqRuntimeStateToken, type QqRuntimeState } from './qq-runtime-state.js';
12
23
 
13
- export type QqCommandReply = (text: string) => Promise<unknown>;
24
+ export type QqCommandReply = EndpointCommandReply;
14
25
 
15
26
  /**
16
27
  * endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
@@ -18,64 +29,33 @@ export type QqCommandReply = (text: string) => Promise<unknown>;
18
29
  * legacy applyBindOwnership 会把 operator 写为新端点的 master)。
19
30
  */
20
31
  export function isQqEndpointOperator(config: unknown, input: unknown): boolean {
21
- const cfg = (config ?? {}) as { master?: unknown; endpoints?: unknown };
22
- const masters = new Set<string>();
23
- const collect = (value: unknown) => {
24
- if (value === undefined || value === null) return;
25
- const text = String(value).trim();
26
- if (text) masters.add(text);
27
- };
28
- collect(cfg.master);
29
- if (Array.isArray(cfg.endpoints)) {
30
- for (const entry of cfg.endpoints) {
31
- collect((entry as { master?: unknown } | null | undefined)?.master);
32
- }
33
- }
34
- if (masters.size === 0) return true;
35
- const sender = String((input as { sender?: unknown } | null | undefined)?.sender ?? '').trim();
36
- return !!sender && masters.has(sender);
32
+ return isEndpointOperator(config, input);
37
33
  }
38
34
 
39
- export const QQ_ENDPOINT_FORBIDDEN = '仅 master 可执行 QQ endpoint 管理命令';
35
+ export const QQ_ENDPOINT_FORBIDDEN = endpointCommandForbidden('QQ');
40
36
 
41
37
  /**
42
38
  * 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
43
39
  */
44
40
  export function extractQqCommandReply(input: unknown): QqCommandReply {
45
- const reply = (input as { $reply?: unknown } | null | undefined)?.$reply;
46
- if (typeof reply === 'function') {
47
- return (text) => (reply as (content: string) => Promise<unknown>).call(input, text);
48
- }
49
- return async () => undefined;
41
+ return extractEndpointCommandReply(input);
50
42
  }
51
43
 
52
44
  /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
53
45
  export function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string {
54
- const running = [...state.endpoints.values()];
55
- const configured = listQqEndpointEntries(projectRoot);
56
- const lines: string[] = [];
57
- lines.push('【运行中的 QQ endpoints】');
58
- if (running.length === 0) {
59
- lines.push(' (无)');
60
- } else {
61
- for (const endpoint of running) {
62
- lines.push(` - ${endpoint.name}(${endpoint.mode})`);
63
- }
64
- }
65
- lines.push('【配置中的 QQ endpoints】(zhin.config.yml → plugins.qq.endpoints)');
66
- if (configured.length === 0) {
67
- lines.push(' (无)');
68
- } else {
69
- for (const entry of configured) {
70
- lines.push(` - ${entry.name}(appid: ${entry.appid})`);
71
- }
72
- }
73
- if (state.bindFlow) {
74
- lines.push('⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消');
75
- }
76
- return lines.join('\n');
46
+ return formatEndpointList(qqEndpointListSpec, {
47
+ running: state.endpoints.values(),
48
+ configured: listQqEndpointEntries(projectRoot),
49
+ footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined,
50
+ });
77
51
  }
78
52
 
53
+ const qqEndpointListSpec = {
54
+ adapterKey: 'qq',
55
+ adapterDisplayName: 'QQ',
56
+ describeEntry: (entry: ConfiguredEndpointEntry) => `appid: ${String(entry.appid)}`,
57
+ } as const;
58
+
79
59
  /**
80
60
  * `qq endpoint add [name]`:启动扫码绑定流程。
81
61
  * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
@@ -157,18 +137,24 @@ export function runQqEndpointRemove(
157
137
  name: string,
158
138
  projectRoot?: string,
159
139
  ): string {
160
- const trimmed = name.trim();
161
- if (!trimmed) return '用法:qq endpoint remove <name>';
162
- try {
163
- const { removed, filePath } = removeQqEndpointFromConfig(trimmed, projectRoot);
164
- if (!removed) {
165
- return `配置中不存在 qq endpoint「${trimmed}」(${filePath} → plugins.qq.endpoints)`;
166
- }
167
- return (
168
- `已从 ${filePath} 的 plugins.qq.endpoints 移除「${trimmed}」。\n` +
169
- '⚠️ 需重启 zhin 后生效(运行中的连接届时才会断开);.env 中的凭据键未删除,可手动清理。'
170
- );
171
- } catch (error) {
172
- return `移除失败:${error instanceof Error ? error.message : String(error)}`;
173
- }
140
+ return removeEndpointByName(qqEndpointListSpec, name, projectRoot);
174
141
  }
142
+
143
+ /**
144
+ * 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
145
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项;cancel 为 QQ 特化,单独定义。
146
+ */
147
+ export const qqEndpointCommands = createEndpointCommands({
148
+ ...qqEndpointListSpec,
149
+ fields: [
150
+ { key: 'appid', required: true, env: true, description: 'QQ 机器人 appid' },
151
+ { key: 'secret', required: true, env: true, description: 'QQ 机器人 secret' },
152
+ ],
153
+ running: (use) => use(qqRuntimeStateToken).endpoints.values(),
154
+ listFooter: (use) => {
155
+ const state = use(qqRuntimeStateToken);
156
+ return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined;
157
+ },
158
+ addDescription: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
159
+ bindFlow: ({ name, reply, use }) => runQqEndpointAdd(use(qqRuntimeStateToken), name, reply),
160
+ }, defineCommand);
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * zhin.config.yml 中 plugins.qq.endpoints 的读写(Plugin Runtime 版)。
3
- * 用 yaml Document 操作以保留现有注释;仅支持 .yml/.yaml,其它格式报错提示手动处理。
3
+ * 实现已泛化到 @zhin.js/adapter 的 endpoint-commands 套件(Document 节点级保留注释);
4
+ * 本文件仅保留 QQ 专用签名以兼容既有调用与测试。
4
5
  */
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { isMap, isSeq, parseDocument, type YAMLSeq } from 'yaml';
8
- import { resolveProjectRoot } from './qq-bind-persist.js';
6
+ import {
7
+ addEndpointToConfig,
8
+ findEndpointConfigFile,
9
+ listConfiguredEndpoints,
10
+ removeEndpointFromConfig,
11
+ } from '@zhin.js/adapter';
9
12
 
10
13
  export interface QqEndpointConfigEntry {
11
14
  name: string;
@@ -14,93 +17,14 @@ export interface QqEndpointConfigEntry {
14
17
  [key: string]: unknown;
15
18
  }
16
19
 
17
- const CONFIG_BASENAME = 'zhin.config';
18
- const YAML_EXTENSIONS = ['.yml', '.yaml'] as const;
19
-
20
20
  /** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
21
21
  export function findQqConfigFile(projectRoot?: string): string {
22
- const root = projectRoot ?? resolveProjectRoot();
23
- const envConfig = process.env.ZHIN_CONFIG?.trim();
24
- if (envConfig) return path.resolve(root, envConfig);
25
- for (const ext of YAML_EXTENSIONS) {
26
- const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
27
- if (fs.existsSync(candidate)) return candidate;
28
- }
29
- for (const ext of ['.json', '.toml', '.ts'] as const) {
30
- const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
31
- if (fs.existsSync(candidate)) {
32
- throw new Error(`暂不支持写入 ${ext} 配置文件,请手动在 ${CONFIG_BASENAME}${ext} 的 plugins.qq.endpoints 中维护`);
33
- }
34
- }
35
- return path.join(root, `${CONFIG_BASENAME}.yml`);
36
- }
37
-
38
- interface QqConfigDocument {
39
- filePath: string;
40
- doc: ReturnType<typeof parseDocument>;
41
- }
42
-
43
- function readConfigDocument(projectRoot?: string): QqConfigDocument {
44
- const filePath = findQqConfigFile(projectRoot);
45
- const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
46
- const doc = parseDocument(content || '{}');
47
- return { filePath, doc };
48
- }
49
-
50
- function writeConfigDocument({ filePath, doc }: QqConfigDocument): void {
51
- fs.writeFileSync(filePath, doc.toString());
22
+ return findEndpointConfigFile('qq', projectRoot);
52
23
  }
53
24
 
54
25
  /** 读取 plugins.qq.endpoints(plain JS);plugins/qq 缺失或形态不符时返回 [] */
55
26
  export function listQqEndpointEntries(projectRoot?: string): QqEndpointConfigEntry[] {
56
- const { doc } = readConfigDocument(projectRoot);
57
- const plugins = doc.toJS()?.plugins;
58
- if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return [];
59
- const endpoints = (plugins as { qq?: { endpoints?: unknown } }).qq?.endpoints;
60
- if (!Array.isArray(endpoints)) return [];
61
- return endpoints.filter(
62
- (entry): entry is QqEndpointConfigEntry =>
63
- !!entry && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string',
64
- );
65
- }
66
-
67
- function entryName(item: unknown): string | undefined {
68
- if (!isMap(item)) return undefined;
69
- const name = item.get('name');
70
- return typeof name === 'string' && name ? name : undefined;
71
- }
72
-
73
- /**
74
- * 确保 plugins.qq.endpoints 存在并返回其 YAMLSeq(节点级操作,保留既有条目与注释)。
75
- * `plugins: []`(legacy 空列表,Runtime 忽略)可直接替换为 map;非空数组拒绝写入。
76
- */
77
- function ensureEndpointsSeq(doc: ReturnType<typeof parseDocument>): YAMLSeq {
78
- const plugins = doc.get('plugins');
79
- if (isSeq(plugins) && plugins.items.length > 0) {
80
- throw new Error('配置的 plugins 是数组形态(legacy 插件名列表),请手动迁移为 map 后再试');
81
- }
82
- if (plugins !== undefined && !isMap(plugins) && !isSeq(plugins)) {
83
- throw new Error('配置的 plugins 字段形态异常,请手动检查 zhin.config.yml');
84
- }
85
- if (!isMap(doc.get('plugins'))) {
86
- // 注意:空对象 {} 不会被 doc.set 自动包装为 YAMLMap,必须显式 createNode
87
- doc.set('plugins', doc.createNode({}));
88
- }
89
- const qq = doc.getIn(['plugins', 'qq']);
90
- if (qq !== undefined && !isMap(qq)) {
91
- throw new Error('配置的 plugins.qq 字段形态异常,请手动检查 zhin.config.yml');
92
- }
93
- if (!isMap(doc.getIn(['plugins', 'qq']))) {
94
- doc.setIn(['plugins', 'qq'], doc.createNode({}));
95
- }
96
- const endpoints = doc.getIn(['plugins', 'qq', 'endpoints']);
97
- if (endpoints !== undefined && !isSeq(endpoints)) {
98
- throw new Error('配置的 plugins.qq.endpoints 字段形态异常,请手动检查 zhin.config.yml');
99
- }
100
- if (!isSeq(doc.getIn(['plugins', 'qq', 'endpoints']))) {
101
- doc.setIn(['plugins', 'qq', 'endpoints'], doc.createNode([]));
102
- }
103
- return doc.getIn(['plugins', 'qq', 'endpoints']) as YAMLSeq;
27
+ return listConfiguredEndpoints('qq', projectRoot) as QqEndpointConfigEntry[];
104
28
  }
105
29
 
106
30
  /** 追加 endpoint 到 plugins.qq.endpoints;name 已存在时报错 */
@@ -108,14 +32,7 @@ export function addQqEndpointToConfig(
108
32
  entry: QqEndpointConfigEntry,
109
33
  projectRoot?: string,
110
34
  ): string {
111
- const document = readConfigDocument(projectRoot);
112
- const seq = ensureEndpointsSeq(document.doc);
113
- if (seq.items.some((item) => entryName(item) === entry.name)) {
114
- throw new Error(`配置中已存在 qq endpoint「${entry.name}」,可先 qq endpoint remove ${entry.name} 再重新添加`);
115
- }
116
- seq.items.push(document.doc.createNode(entry));
117
- writeConfigDocument(document);
118
- return document.filePath;
35
+ return addEndpointToConfig('qq', entry, projectRoot);
119
36
  }
120
37
 
121
38
  /** 按 name 移除 plugins.qq.endpoints 项;不存在返回 false */
@@ -123,13 +40,5 @@ export function removeQqEndpointFromConfig(
123
40
  name: string,
124
41
  projectRoot?: string,
125
42
  ): { removed: boolean; filePath: string } {
126
- const document = readConfigDocument(projectRoot);
127
- const seq = ensureEndpointsSeq(document.doc);
128
- const next = seq.items.filter((item) => entryName(item) !== name);
129
- if (next.length === seq.items.length) {
130
- return { removed: false, filePath: document.filePath };
131
- }
132
- seq.items = next;
133
- writeConfigDocument(document);
134
- return { removed: true, filePath: document.filePath };
43
+ return removeEndpointFromConfig('qq', name, projectRoot);
135
44
  }