mioku-service-webui 2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jerry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # mioku-service-webui
2
+ 为 mioku 提供web-ui服务
package/auth.ts ADDED
@@ -0,0 +1,48 @@
1
+ import * as crypto from "node:crypto";
2
+ import type { Context, Next } from "hono";
3
+ import type { AuthConfig } from "./types";
4
+ import { AUTH_PATH, WEEK_MS, readJsonFile, writeJsonFile } from "./utils";
5
+
6
+ export function ensureAuthConfig(): AuthConfig {
7
+ const now = Date.now();
8
+ const existing = readJsonFile<AuthConfig | null>(AUTH_PATH, null);
9
+
10
+ if (!existing || !existing.token || !existing.expiresAt || existing.expiresAt <= now) {
11
+ const generated: AuthConfig = {
12
+ token: crypto.randomBytes(24).toString("hex"),
13
+ createdAt: now,
14
+ expiresAt: now + WEEK_MS,
15
+ };
16
+ writeJsonFile(AUTH_PATH, generated);
17
+ return generated;
18
+ }
19
+
20
+ return existing;
21
+ }
22
+
23
+ export function verifyAuthHeader(c: Context): boolean {
24
+ const auth = c.req.header("authorization") || "";
25
+ const token = auth.replace(/^Bearer\s+/i, "").trim();
26
+ if (!token) {
27
+ return false;
28
+ }
29
+
30
+ const authConfig = ensureAuthConfig();
31
+ return token === authConfig.token && Date.now() < authConfig.expiresAt;
32
+ }
33
+
34
+ export async function requireAuth(c: Context, next: Next): Promise<Response | void> {
35
+ if (!verifyAuthHeader(c)) {
36
+ return c.json({ ok: false, error: "UNAUTHORIZED" }, 401);
37
+ }
38
+ await next();
39
+ }
40
+
41
+ export function loginWithToken(inputToken: string): { ok: boolean; expiresAt?: number } {
42
+ const authConfig = ensureAuthConfig();
43
+ if (inputToken !== authConfig.token) {
44
+ return { ok: false };
45
+ }
46
+
47
+ return { ok: true, expiresAt: authConfig.expiresAt };
48
+ }
@@ -0,0 +1,109 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import matter from "gray-matter";
4
+ import { logger } from "mioki";
5
+
6
+ export interface ConfigField {
7
+ key: string;
8
+ label: string;
9
+ type: "text" | "textarea" | "number" | "switch" | "select" | "multi-select" | "secret" | "json";
10
+ description?: string;
11
+ placeholder?: string;
12
+ required?: boolean;
13
+ multiple?: boolean;
14
+ source?: string;
15
+ options?: Array<{ value: string; label: string }>;
16
+ defaultValue?: any;
17
+ }
18
+
19
+ export interface ConfigPageManifest {
20
+ plugin: string;
21
+ title: string;
22
+ description?: string;
23
+ markdown: string;
24
+ fields: ConfigField[];
25
+ hasCustomPage: boolean;
26
+ }
27
+
28
+ export function loadPluginConfigPage(pluginName: string): ConfigPageManifest | null {
29
+ const pluginDir = path.join(process.cwd(), "plugins", pluginName);
30
+ const configMdPath = path.join(pluginDir, "config.md");
31
+
32
+ if (!fs.existsSync(configMdPath)) {
33
+ return null;
34
+ }
35
+
36
+ try {
37
+ const fileContent = fs.readFileSync(configMdPath, "utf-8");
38
+ const parsed = matter(fileContent);
39
+
40
+ const frontmatter = parsed.data as any;
41
+ const fields = (frontmatter.fields || []) as ConfigField[];
42
+
43
+ // Validate fields
44
+ for (const field of fields) {
45
+ if (!field.key || !field.label || !field.type) {
46
+ logger.warn(`Invalid field in ${pluginName}/config.md: missing key, label, or type`);
47
+ continue;
48
+ }
49
+
50
+ // Validate key format: <configName>.<jsonPath>
51
+ if (!field.key.includes(".")) {
52
+ logger.warn(`Invalid field key in ${pluginName}/config.md: ${field.key} (must be <configName>.<path>)`);
53
+ }
54
+ }
55
+
56
+ return {
57
+ plugin: pluginName,
58
+ title: frontmatter.title || `${pluginName} Configuration`,
59
+ description: frontmatter.description,
60
+ markdown: parsed.content,
61
+ fields,
62
+ hasCustomPage: true,
63
+ };
64
+ } catch (error: any) {
65
+ logger.error(`Failed to load config page for ${pluginName}: ${error.message}`);
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export function parseConfigKey(key: string): { configName: string; path: string } | null {
71
+ const parts = key.split(".");
72
+ if (parts.length < 2) {
73
+ return null;
74
+ }
75
+
76
+ return {
77
+ configName: parts[0],
78
+ path: parts.slice(1).join("."),
79
+ };
80
+ }
81
+
82
+ export function getValueByPath(obj: any, path: string): any {
83
+ const keys = path.split(".");
84
+ let current = obj;
85
+
86
+ for (const key of keys) {
87
+ if (current == null || typeof current !== "object") {
88
+ return undefined;
89
+ }
90
+ current = current[key];
91
+ }
92
+
93
+ return current;
94
+ }
95
+
96
+ export function setValueByPath(obj: any, path: string, value: any): void {
97
+ const keys = path.split(".");
98
+ let current = obj;
99
+
100
+ for (let i = 0; i < keys.length - 1; i++) {
101
+ const key = keys[i];
102
+ if (!(key in current) || typeof current[key] !== "object") {
103
+ current[key] = {};
104
+ }
105
+ current = current[key];
106
+ }
107
+
108
+ current[keys[keys.length - 1]] = value;
109
+ }
package/database.ts ADDED
@@ -0,0 +1,322 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { CHAT_DATA_DIR, readJsonFile, writeJsonFile } from "./utils";
5
+
6
+ const DB_PATH = path.join(CHAT_DATA_DIR, "chat.db");
7
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+
9
+ function openDb(): any {
10
+ if (!fs.existsSync(DB_PATH)) {
11
+ throw new Error("数据库不存在");
12
+ }
13
+ return new Database(DB_PATH, { readonly: false });
14
+ }
15
+
16
+ function getTableColumns(db: any, table: string): string[] {
17
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
18
+ return rows.map((row) => row.name);
19
+ }
20
+
21
+ function ensureSafeIdentifier(value: string, label: string): string {
22
+ if (!SAFE_IDENTIFIER.test(value)) {
23
+ throw new Error(`${label} 非法`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ function resolveTimeColumn(columns: string[]): string | null {
29
+ if (columns.includes("created_at")) return "created_at";
30
+ if (columns.includes("timestamp")) return "timestamp";
31
+ if (columns.includes("updated_at")) return "updated_at";
32
+ return null;
33
+ }
34
+
35
+ export function listTables(): string[] {
36
+ const db = openDb();
37
+ try {
38
+ const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>;
39
+ return rows.map((row) => row.name);
40
+ } finally {
41
+ db.close();
42
+ }
43
+ }
44
+
45
+ export function queryMessages(input: {
46
+ table?: string;
47
+ keyword?: string;
48
+ userId?: string;
49
+ sessionId?: string;
50
+ startTime?: number;
51
+ endTime?: number;
52
+ page?: number;
53
+ pageSize?: number;
54
+ }): Record<string, any> {
55
+ const db = openDb();
56
+ try {
57
+ const table = ensureSafeIdentifier(input.table || "messages", "表名");
58
+ const columns = getTableColumns(db, table);
59
+ const timeColumn = resolveTimeColumn(columns);
60
+
61
+ const page = Math.max(1, Number(input.page || 1));
62
+ const pageSize = Math.min(200, Math.max(1, Number(input.pageSize || 20)));
63
+ const offset = (page - 1) * pageSize;
64
+
65
+ const conditions: string[] = [];
66
+ const params: Record<string, any> = {};
67
+
68
+ if (input.keyword && columns.includes("content")) {
69
+ conditions.push("content LIKE @keyword");
70
+ params.keyword = `%${input.keyword}%`;
71
+ }
72
+ if (input.userId && columns.includes("user_id")) {
73
+ conditions.push("user_id = @userId");
74
+ params.userId = input.userId;
75
+ }
76
+ if (input.sessionId && columns.includes("session_id")) {
77
+ conditions.push("session_id = @sessionId");
78
+ params.sessionId = input.sessionId;
79
+ }
80
+ if (input.startTime && timeColumn) {
81
+ conditions.push(`${timeColumn} >= @startTime`);
82
+ params.startTime = input.startTime;
83
+ }
84
+ if (input.endTime && timeColumn) {
85
+ conditions.push(`${timeColumn} <= @endTime`);
86
+ params.endTime = input.endTime;
87
+ }
88
+
89
+ const whereSql = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
90
+
91
+ const countStmt = db.prepare(`SELECT COUNT(*) as count FROM ${table} ${whereSql}`);
92
+ const total = Number((countStmt.get(params) as any)?.count || 0);
93
+
94
+ const orderField = timeColumn || columns[0] || "rowid";
95
+ const rowsStmt = db.prepare(`SELECT * FROM ${table} ${whereSql} ORDER BY ${orderField} DESC LIMIT @limit OFFSET @offset`);
96
+ const rows = rowsStmt.all({ ...params, limit: pageSize, offset });
97
+
98
+ return { total, page, pageSize, rows, table, columns };
99
+ } finally {
100
+ db.close();
101
+ }
102
+ }
103
+
104
+ export function updateMessage(input: { table?: string; idField?: string; id: string | number; content: string }): Record<string, any> {
105
+ const db = openDb();
106
+ try {
107
+ const table = ensureSafeIdentifier(input.table || "messages", "表名");
108
+ const idField = ensureSafeIdentifier(input.idField || "id", "主键字段");
109
+ const stmt = db.prepare(`UPDATE ${table} SET content = ? WHERE ${idField} = ?`);
110
+ const result = stmt.run(input.content, input.id);
111
+ return { changed: result.changes };
112
+ } finally {
113
+ db.close();
114
+ }
115
+ }
116
+
117
+ export function deleteMessagesByRange(input: { table?: string; startTime: number; endTime: number }): Record<string, any> {
118
+ const db = openDb();
119
+ try {
120
+ const table = ensureSafeIdentifier(input.table || "messages", "表名");
121
+ const columns = getTableColumns(db, table);
122
+ const timeColumn = resolveTimeColumn(columns);
123
+ if (!timeColumn) {
124
+ throw new Error(`${table} 不包含时间字段(created_at/timestamp/updated_at)`);
125
+ }
126
+ const stmt = db.prepare(`DELETE FROM ${table} WHERE ${timeColumn} >= ? AND ${timeColumn} <= ?`);
127
+ const result = stmt.run(input.startTime, input.endTime);
128
+ return { deleted: result.changes };
129
+ } finally {
130
+ db.close();
131
+ }
132
+ }
133
+
134
+ export function getStats(): Record<string, any> {
135
+ const db = openDb();
136
+ try {
137
+ const tables = (db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>).map(
138
+ (row) => row.name,
139
+ );
140
+
141
+ const hasMessages = tables.includes("messages");
142
+ const hasImages = tables.includes("images");
143
+ const messageColumns = hasMessages ? getTableColumns(db, "messages") : [];
144
+ const messageTimeColumn = resolveTimeColumn(messageColumns);
145
+
146
+ const messageTotal = hasMessages ? Number((db.prepare("SELECT COUNT(*) as count FROM messages").get() as any)?.count || 0) : 0;
147
+ const activeUsers = hasMessages
148
+ ? Number((db.prepare("SELECT COUNT(DISTINCT user_id) as count FROM messages").get() as any)?.count || 0)
149
+ : 0;
150
+
151
+ const aiCalls = hasMessages
152
+ ? Number((db.prepare("SELECT COUNT(*) as count FROM messages WHERE role='assistant'").get() as any)?.count || 0)
153
+ : 0;
154
+
155
+ const timeline = hasMessages && messageTimeColumn
156
+ ? db
157
+ .prepare(
158
+ `SELECT strftime('%Y-%m-%d', datetime(${messageTimeColumn} / 1000, 'unixepoch')) as day, COUNT(*) as count FROM messages GROUP BY day ORDER BY day DESC LIMIT 30`,
159
+ )
160
+ .all()
161
+ : [];
162
+
163
+ const topTopics = hasMessages
164
+ ? db
165
+ .prepare(
166
+ "SELECT substr(content, 1, 20) as topic, COUNT(*) as count FROM messages WHERE content IS NOT NULL AND content != '' GROUP BY topic ORDER BY count DESC LIMIT 10",
167
+ )
168
+ .all()
169
+ : [];
170
+
171
+ const imageStats = hasImages
172
+ ? db
173
+ .prepare(
174
+ "SELECT COALESCE(NULLIF(TRIM(character), ''), 'unknown') || '/' || COALESCE(NULLIF(TRIM(emotion), ''), 'default') as name, COUNT(*) as count FROM images GROUP BY COALESCE(NULLIF(TRIM(character), ''), 'unknown'), COALESCE(NULLIF(TRIM(emotion), ''), 'default') ORDER BY count DESC LIMIT 20",
175
+ )
176
+ .all()
177
+ : [];
178
+
179
+ return {
180
+ messageTotal,
181
+ activeUsers,
182
+ aiCalls,
183
+ timeline,
184
+ topTopics,
185
+ imageStats,
186
+ };
187
+ } finally {
188
+ db.close();
189
+ }
190
+ }
191
+
192
+ export function exportData(format: "json" | "csv"): { filePath: string } {
193
+ const db = openDb();
194
+ try {
195
+ const tables = listTables();
196
+ const dump: Record<string, any[]> = {};
197
+
198
+ for (const table of tables) {
199
+ dump[table] = db.prepare(`SELECT * FROM ${table}`).all();
200
+ }
201
+
202
+ const backupDir = path.join(CHAT_DATA_DIR, "backup");
203
+ if (!fs.existsSync(backupDir)) {
204
+ fs.mkdirSync(backupDir, { recursive: true });
205
+ }
206
+
207
+ const ts = Date.now();
208
+ if (format === "json") {
209
+ const filePath = path.join(backupDir, `chat-backup-${ts}.json`);
210
+ fs.writeFileSync(filePath, JSON.stringify(dump, null, 2), "utf-8");
211
+ return { filePath };
212
+ }
213
+
214
+ const rows = dump.messages ?? [];
215
+ const headers = rows.length ? Object.keys(rows[0]) : [];
216
+ const csvLines = [headers.join(",")].concat(
217
+ rows.map((row) =>
218
+ headers
219
+ .map((key) => {
220
+ const raw = row[key] == null ? "" : String(row[key]);
221
+ return `"${raw.replace(/"/g, '""')}"`;
222
+ })
223
+ .join(","),
224
+ ),
225
+ );
226
+
227
+ const filePath = path.join(backupDir, `chat-backup-${ts}.csv`);
228
+ fs.writeFileSync(filePath, csvLines.join("\n"), "utf-8");
229
+ return { filePath };
230
+ } finally {
231
+ db.close();
232
+ }
233
+ }
234
+
235
+ export function importDataFromJson(filePath: string): { importedTables: string[] } {
236
+ const payload = readJsonFile<Record<string, any[]>>(filePath, {});
237
+ const db = openDb();
238
+
239
+ try {
240
+ const importedTables: string[] = [];
241
+
242
+ db.exec("BEGIN");
243
+ for (const [table, rows] of Object.entries(payload)) {
244
+ ensureSafeIdentifier(table, "表名");
245
+ if (!Array.isArray(rows) || rows.length === 0) {
246
+ continue;
247
+ }
248
+ const columns = Object.keys(rows[0]);
249
+ const placeholders = columns.map(() => "?").join(",");
250
+ const insert = db.prepare(
251
+ `INSERT INTO ${table} (${columns.join(",")}) VALUES (${placeholders})`,
252
+ );
253
+
254
+ for (const row of rows) {
255
+ insert.run(columns.map((column) => (row as any)[column]));
256
+ }
257
+ importedTables.push(table);
258
+ }
259
+ db.exec("COMMIT");
260
+
261
+ return { importedTables };
262
+ } catch (error) {
263
+ db.exec("ROLLBACK");
264
+ throw error;
265
+ } finally {
266
+ db.close();
267
+ }
268
+ }
269
+
270
+ export function listMemeTree(): Record<string, Record<string, string[]>> {
271
+ const root = path.join(CHAT_DATA_DIR, "meme");
272
+ const tree: Record<string, Record<string, string[]>> = {};
273
+
274
+ if (!fs.existsSync(root)) {
275
+ return tree;
276
+ }
277
+
278
+ const characters = fs.readdirSync(root);
279
+ for (const character of characters) {
280
+ const charDir = path.join(root, character);
281
+ if (!fs.statSync(charDir).isDirectory()) {
282
+ continue;
283
+ }
284
+
285
+ tree[character] = {};
286
+ const emotions = fs.readdirSync(charDir);
287
+ for (const emotion of emotions) {
288
+ const emotionDir = path.join(charDir, emotion);
289
+ if (!fs.statSync(emotionDir).isDirectory()) {
290
+ continue;
291
+ }
292
+ tree[character][emotion] = fs
293
+ .readdirSync(emotionDir)
294
+ .filter((name) => /\.(png|jpg|jpeg|gif|webp)$/i.test(name))
295
+ .map((name) => path.join("data", "chat", "meme", character, emotion, name));
296
+ }
297
+ }
298
+
299
+ return tree;
300
+ }
301
+
302
+ export function savePluginConfig(pluginName: string, fileName: string, value: any): void {
303
+ const filePath = path.join(process.cwd(), "config", pluginName, `${fileName}.json`);
304
+ writeJsonFile(filePath, value);
305
+ }
306
+
307
+ export function getPluginConfigs(pluginName: string): Record<string, any> {
308
+ const pluginDir = path.join(process.cwd(), "config", pluginName);
309
+ if (!fs.existsSync(pluginDir)) {
310
+ return {};
311
+ }
312
+
313
+ const result: Record<string, any> = {};
314
+ const files = fs.readdirSync(pluginDir).filter((name) => name.endsWith(".json"));
315
+
316
+ for (const file of files) {
317
+ const filePath = path.join(pluginDir, file);
318
+ result[path.basename(file, ".json")] = readJsonFile(filePath, {});
319
+ }
320
+
321
+ return result;
322
+ }
package/datasources.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { connectedBots, logger } from "mioki";
2
+
3
+ export interface DatasourceOption {
4
+ value: string;
5
+ label: string;
6
+ description?: string;
7
+ meta?: Record<string, any>;
8
+ }
9
+
10
+ export interface DatasourceQuery {
11
+ q?: string;
12
+ limit?: number;
13
+ }
14
+
15
+ export type DatasourceProvider = (
16
+ query?: DatasourceQuery,
17
+ ) => Promise<DatasourceOption[]>;
18
+
19
+ const datasources = new Map<string, DatasourceProvider>();
20
+
21
+ export function registerDatasource(name: string, provider: DatasourceProvider): void {
22
+ datasources.set(name, provider);
23
+ logger.info(`Registered datasource: ${name}`);
24
+ }
25
+
26
+ function normalizeText(value: unknown): string {
27
+ return String(value ?? "").trim().toLowerCase();
28
+ }
29
+
30
+ function matchesQuery(option: DatasourceOption, query?: string): boolean {
31
+ const q = normalizeText(query);
32
+ if (!q) {
33
+ return true;
34
+ }
35
+
36
+ const haystacks = [
37
+ option.value,
38
+ option.label,
39
+ option.description,
40
+ option.meta?.searchText,
41
+ option.meta?.qq,
42
+ option.meta?.groupId,
43
+ option.meta?.nickname,
44
+ option.meta?.remark,
45
+ option.meta?.groupName,
46
+ ]
47
+ .map((item) => normalizeText(item))
48
+ .filter(Boolean);
49
+
50
+ return haystacks.some((item) => item.includes(q));
51
+ }
52
+
53
+ function limitOptions(
54
+ options: DatasourceOption[],
55
+ limit?: number,
56
+ ): DatasourceOption[] {
57
+ if (!limit || limit <= 0) {
58
+ return options;
59
+ }
60
+ return options.slice(0, limit);
61
+ }
62
+
63
+ export async function getDatasource(
64
+ name: string,
65
+ query?: DatasourceQuery,
66
+ ): Promise<DatasourceOption[]> {
67
+ const provider = datasources.get(name);
68
+ if (!provider) {
69
+ logger.warn(`Datasource not found: ${name}`);
70
+ return [];
71
+ }
72
+
73
+ try {
74
+ const options = await provider(query);
75
+ return limitOptions(
76
+ options.filter((option) => matchesQuery(option, query?.q)),
77
+ query?.limit,
78
+ );
79
+ } catch (error: any) {
80
+ logger.error(`Failed to fetch datasource ${name}: ${error.message}`);
81
+ return [];
82
+ }
83
+ }
84
+
85
+ export function listDatasources(): string[] {
86
+ return Array.from(datasources.keys());
87
+ }
88
+
89
+ function buildFriendAvatar(userId: string): string {
90
+ return `https://q1.qlogo.cn/g?b=qq&nk=${encodeURIComponent(userId)}&s=100`;
91
+ }
92
+
93
+ function buildGroupAvatar(groupId: string): string {
94
+ return `https://p.qlogo.cn/gh/${encodeURIComponent(groupId)}/${encodeURIComponent(groupId)}/100`;
95
+ }
96
+
97
+ function getConnectedBots(): any[] {
98
+ return Array.from(connectedBots.values()).filter(Boolean);
99
+ }
100
+
101
+ async function fetchFriendOptions(): Promise<DatasourceOption[]> {
102
+ const bots = getConnectedBots();
103
+ const merged = new Map<string, DatasourceOption>();
104
+
105
+ await Promise.all(
106
+ bots.map(async (bot) => {
107
+ const list = await bot.getFriendList().catch(() => []);
108
+ for (const item of Array.isArray(list) ? list : []) {
109
+ const userId = String(item?.user_id ?? "").trim();
110
+ if (!userId) {
111
+ continue;
112
+ }
113
+ const nickname = String(item?.nickname ?? "").trim();
114
+ const remark = String(item?.remark ?? "").trim();
115
+ const label = remark || nickname || userId;
116
+ merged.set(userId, {
117
+ value: userId,
118
+ label,
119
+ description: `QQ ${userId}${nickname && remark && nickname !== remark ? ` · ${nickname}` : ""}`,
120
+ meta: {
121
+ type: "qq_friend",
122
+ qq: userId,
123
+ nickname,
124
+ remark,
125
+ avatarUrl: buildFriendAvatar(userId),
126
+ searchText: [userId, nickname, remark].filter(Boolean).join(" "),
127
+ },
128
+ });
129
+ }
130
+ }),
131
+ );
132
+
133
+ return Array.from(merged.values()).sort((a, b) =>
134
+ a.label.localeCompare(b.label, "zh-Hans-CN"),
135
+ );
136
+ }
137
+
138
+ async function fetchGroupOptions(): Promise<DatasourceOption[]> {
139
+ const bots = getConnectedBots();
140
+ const merged = new Map<string, DatasourceOption>();
141
+
142
+ await Promise.all(
143
+ bots.map(async (bot) => {
144
+ const list = await bot.getGroupList().catch(() => []);
145
+ for (const item of Array.isArray(list) ? list : []) {
146
+ const groupId = String(item?.group_id ?? "").trim();
147
+ if (!groupId) {
148
+ continue;
149
+ }
150
+ const groupName = String(item?.group_name ?? "").trim() || groupId;
151
+ const memberCount = Number(item?.member_count);
152
+ merged.set(groupId, {
153
+ value: groupId,
154
+ label: groupName,
155
+ description: Number.isFinite(memberCount)
156
+ ? `${groupId} · ${memberCount} 人`
157
+ : `群号 ${groupId}`,
158
+ meta: {
159
+ type: "qq_group",
160
+ groupId,
161
+ groupName,
162
+ memberCount: Number.isFinite(memberCount) ? memberCount : undefined,
163
+ avatarUrl: buildGroupAvatar(groupId),
164
+ searchText: [groupId, groupName].filter(Boolean).join(" "),
165
+ },
166
+ });
167
+ }
168
+ }),
169
+ );
170
+
171
+ return Array.from(merged.values()).sort((a, b) =>
172
+ a.label.localeCompare(b.label, "zh-Hans-CN"),
173
+ );
174
+ }
175
+
176
+ // Built-in datasources
177
+ export function initBuiltinDatasources(): void {
178
+ // QQ groups datasource
179
+ registerDatasource("qq_groups", async () => {
180
+ return fetchGroupOptions();
181
+ });
182
+
183
+ // QQ friends datasource
184
+ registerDatasource("qq_friends", async () => {
185
+ return fetchFriendOptions();
186
+ });
187
+ }