@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.
- package/CHANGELOG.md +54 -0
- package/adapters/qq.js +49 -0
- package/adapters/qq.ts +22 -9
- package/commands/endpoint/add/[name:string=].js +3 -0
- package/commands/endpoint/add/[name:string=].ts +2 -20
- package/commands/endpoint/cancel.js +12 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +2 -9
- package/commands/endpoint/remove/[name:string].js +3 -0
- package/commands/endpoint/remove/[name:string].ts +2 -14
- package/lib/endpoint.js +6 -4
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/outbound.d.ts +17 -0
- package/lib/outbound.js +241 -0
- package/lib/protocol.js +15 -2
- package/lib/qq-bind-persist.d.ts +2 -2
- package/lib/qq-bind-persist.js +6 -35
- package/lib/qq-endpoint-commands.d.ts +16 -3
- package/lib/qq-endpoint-commands.js +40 -65
- package/lib/qq-endpoint-config.js +7 -95
- package/lib/webhook.js +4 -11
- package/lib/ws.d.ts +25 -4
- package/lib/ws.js +18 -1
- package/package.json +16 -17
- package/plugin.js +16 -0
- package/src/endpoint.ts +8 -4
- package/src/index.ts +7 -0
- package/src/outbound.ts +262 -0
- package/src/protocol.ts +16 -2
- package/src/qq-bind-persist.ts +16 -43
- package/src/qq-endpoint-commands.ts +52 -66
- package/src/qq-endpoint-config.ts +12 -103
- package/src/webhook.ts +9 -15
- package/src/ws.ts +37 -10
- package/plugin.ts +0 -16
package/src/outbound.ts
ADDED
|
@@ -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
|
-
|
|
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:
|
package/src/qq-bind-persist.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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
|
|
14
|
-
const
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
+
* 实现已泛化到 @zhin.js/adapter 的 endpoint-commands 套件(Document 节点级保留注释);
|
|
4
|
+
* 本文件仅保留 QQ 专用签名以兼容既有调用与测试。
|
|
4
5
|
*/
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|