mioku-service-webui 4.0.9 → 4.3.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/index.ts CHANGED
@@ -8,7 +8,12 @@ import { createNodeWebSocket } from "@hono/node-ws";
8
8
  import type { MiokuService } from "mioku";
9
9
  import type { WebUISettings } from "./types";
10
10
  import { ensureAuthConfig, loginWithToken, requireAuth } from "./auth";
11
- import { ensureWebUIDist, getWebUISettings, getSystemOverview, getSaying } from "./system";
11
+ import {
12
+ ensureWebUIDist,
13
+ getWebUISettings,
14
+ getSystemOverview,
15
+ getSaying,
16
+ } from "./system";
12
17
  import { CHAT_CONFIG_DIR, CHAT_DATA_DIR, LOGS_DIR, WEBUI_DIST } from "./utils";
13
18
  import { initBuiltinDatasources } from "./datasources";
14
19
  import {
@@ -21,6 +26,8 @@ import {
21
26
  createManageRoutes,
22
27
  createStoreRoutes,
23
28
  createDataManagementRoutes,
29
+ createStrangerRoutes,
30
+ createAccessControlRoutes,
24
31
  } from "./routes";
25
32
 
26
33
  export interface WebUIServiceAPI {
@@ -100,6 +107,8 @@ class WebUIRuntime {
100
107
  this.app.route("/api/plugin-config", createPluginConfigRoutes());
101
108
  this.app.route("/api/meme", createMemeRoutes());
102
109
  this.app.route("/api/data-management", createDataManagementRoutes());
110
+ this.app.route("/api/stranger", createStrangerRoutes());
111
+ this.app.route("/api/access-control", createAccessControlRoutes());
103
112
 
104
113
  this.app.get(
105
114
  "/api/ws/logs",
@@ -211,7 +220,7 @@ const runtime = new WebUIRuntime();
211
220
 
212
221
  const webUIService: MiokuService = {
213
222
  name: "webui",
214
- version: "2.0.0",
223
+ version: "4.2.0",
215
224
  description: "Mioku WebUI 管理服务",
216
225
  api: {
217
226
  getSettings: () => getWebUISettings(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-service-webui",
3
- "version": "4.0.9",
3
+ "version": "4.3.0",
4
4
  "description": "Mioku WebUI 服务",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,162 @@
1
+ import { Hono } from "hono";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { logger } from "mioki";
5
+ import { readJsonFile, writeJsonFile, NODE_MODULES_DIR } from "../utils";
6
+
7
+ const ACCESS_CONFIG_PATH = path.resolve(
8
+ process.cwd(),
9
+ "config/boot/access-control.json",
10
+ );
11
+
12
+ const DEFAULT_CONFIG = {
13
+ version: 1,
14
+ global: { plugins: {}, commands: {} },
15
+ groups: {},
16
+ users: {},
17
+ };
18
+
19
+ export function normalizeAccessConfig(input: any) {
20
+ return {
21
+ version: 1,
22
+ global: {
23
+ plugins: input?.global?.plugins ?? {},
24
+ commands: input?.global?.commands ?? {},
25
+ },
26
+ groups: input?.groups ?? {},
27
+ users: input?.users ?? {},
28
+ };
29
+ }
30
+
31
+ type AccessItemKind = "plugin" | "command";
32
+
33
+ interface AccessItem {
34
+ kind: AccessItemKind;
35
+ plugin: string;
36
+ id: string;
37
+ label: string;
38
+ desc?: string;
39
+ match?: string;
40
+ event?: string;
41
+ fromHook: boolean;
42
+ }
43
+
44
+ function readAccessCatalog(): AccessItem[] {
45
+ if (!fs.existsSync(NODE_MODULES_DIR)) return [];
46
+ const entries = fs.readdirSync(NODE_MODULES_DIR, { withFileTypes: true });
47
+ const items: AccessItem[] = [];
48
+
49
+ for (const entry of entries) {
50
+ if (!entry.name.startsWith("mioku-plugin-")) continue;
51
+ const fullPath = path.join(NODE_MODULES_DIR, entry.name);
52
+ let stat: fs.Stats;
53
+ try {
54
+ stat = fs.lstatSync(fullPath);
55
+ } catch {
56
+ continue;
57
+ }
58
+ if (!stat.isDirectory() && !stat.isSymbolicLink()) continue;
59
+
60
+ const pkgPath = path.join(fullPath, "package.json");
61
+ if (!fs.existsSync(pkgPath)) continue;
62
+ let pkg: any;
63
+ try {
64
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
65
+ } catch {
66
+ continue;
67
+ }
68
+
69
+ const name = entry.name.replace(/^mioku-plugin-/, "");
70
+ const mioku = pkg.mioku || {};
71
+ const help = mioku.help || { title: name, description: "", commands: [] };
72
+ const hooks: Array<{ id: string; match?: string; event?: string; description?: string }> =
73
+ mioku.accessHooks || [];
74
+ const helpCommands: Array<any> = help.commands || [];
75
+
76
+ items.push({
77
+ kind: "plugin",
78
+ plugin: name,
79
+ id: name,
80
+ label: help.title || name,
81
+ desc: help.description,
82
+ fromHook: false,
83
+ });
84
+
85
+ const seen = new Set<string>();
86
+ for (const hook of hooks) {
87
+ seen.add(hook.id);
88
+ items.push({
89
+ kind: "command",
90
+ plugin: name,
91
+ id: hook.id,
92
+ label: hook.id,
93
+ desc: hook.description,
94
+ match: hook.match,
95
+ event: hook.event,
96
+ fromHook: true,
97
+ });
98
+ }
99
+ for (const c of helpCommands) {
100
+ const id = String(c.cmd || "").trim();
101
+ if (!id || seen.has(id)) continue;
102
+ items.push({
103
+ kind: "command",
104
+ plugin: name,
105
+ id,
106
+ label: id,
107
+ desc: c.desc,
108
+ fromHook: false,
109
+ });
110
+ }
111
+ }
112
+
113
+ return items.sort((a, b) => {
114
+ if (a.kind !== b.kind) return a.kind === "plugin" ? -1 : 1;
115
+ if (a.plugin !== b.plugin) return a.plugin.localeCompare(b.plugin);
116
+ return a.id.localeCompare(b.id);
117
+ });
118
+ }
119
+
120
+ function filterCatalog(items: AccessItem[], query?: string): AccessItem[] {
121
+ if (!query) return items;
122
+ const q = query.trim().toLowerCase();
123
+ if (!q) return items;
124
+ return items.filter((it) => {
125
+ return (
126
+ it.id.toLowerCase().includes(q) ||
127
+ it.label.toLowerCase().includes(q) ||
128
+ it.plugin.toLowerCase().includes(q) ||
129
+ (it.desc || "").toLowerCase().includes(q)
130
+ );
131
+ });
132
+ }
133
+
134
+ export function createAccessControlRoutes() {
135
+ const app = new Hono();
136
+
137
+ app.get("/", (c) => {
138
+ const data = readJsonFile(ACCESS_CONFIG_PATH, DEFAULT_CONFIG);
139
+ return c.json({ ok: true, data: normalizeAccessConfig(data) });
140
+ });
141
+
142
+ app.put("/", async (c) => {
143
+ const body = await c.req.json().catch(() => null);
144
+ if (!body) {
145
+ return c.json({ ok: false, error: "INVALID_BODY" }, 400);
146
+ }
147
+ const data = normalizeAccessConfig(body);
148
+ writeJsonFile(ACCESS_CONFIG_PATH, data);
149
+ logger.info(
150
+ `[webui-action] access-control.update | groups=${Object.keys(data.groups).length} users=${Object.keys(data.users).length}`,
151
+ );
152
+ return c.json({ ok: true, data });
153
+ });
154
+
155
+ app.get("/catalog", (c) => {
156
+ const q = c.req.query("q");
157
+ const data = filterCatalog(readAccessCatalog(), q);
158
+ return c.json({ ok: true, data });
159
+ });
160
+
161
+ return app;
162
+ }
package/routes/index.ts CHANGED
@@ -5,3 +5,5 @@ export { createDBRoutes, createPluginConfigRoutes, createMemeRoutes } from "./db
5
5
  export { createManageRoutes } from "./manage";
6
6
  export { createStoreRoutes } from "./store";
7
7
  export { createDataManagementRoutes } from "./data-management";
8
+ export { createStrangerRoutes } from "./stranger";
9
+ export { createAccessControlRoutes } from "./access-control";
@@ -0,0 +1,145 @@
1
+ import { Hono } from "hono";
2
+ import { connectedBots, logger } from "mioki";
3
+
4
+ interface StrangerInfoResponse {
5
+ ok: boolean;
6
+ data?: { userId: string; nickname: string };
7
+ error?: string;
8
+ }
9
+
10
+ interface CacheEntry {
11
+ nickname: string | null;
12
+ expiresAt: number;
13
+ }
14
+
15
+ const CACHE_TTL_MS = 5 * 60 * 1000;
16
+ const FETCH_TIMEOUT_MS = 4000;
17
+
18
+ const nicknameCache = new Map<string, CacheEntry>();
19
+ const inflight = new Map<string, Promise<string | null>>();
20
+
21
+ function getConnectedBots(): any[] {
22
+ return Array.from(connectedBots.values()).filter(Boolean);
23
+ }
24
+
25
+ function normalizeUserId(input: unknown): string {
26
+ return String(input ?? "").trim();
27
+ }
28
+
29
+ function isValidQQNumber(id: string): boolean {
30
+ return /^\d{5,12}$/.test(id);
31
+ }
32
+
33
+ function getCached(userId: string): string | null | undefined {
34
+ const entry = nicknameCache.get(userId);
35
+ if (!entry) return undefined;
36
+ if (entry.expiresAt < Date.now()) {
37
+ nicknameCache.delete(userId);
38
+ return undefined;
39
+ }
40
+ return entry.nickname;
41
+ }
42
+
43
+ function setCached(userId: string, nickname: string | null): void {
44
+ nicknameCache.set(userId, {
45
+ nickname,
46
+ expiresAt: Date.now() + CACHE_TTL_MS,
47
+ });
48
+ }
49
+
50
+ async function fetchNicknameFromBot(
51
+ bot: any,
52
+ userId: string,
53
+ ): Promise<string | null> {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
56
+ try {
57
+ const res = await bot.api("get_stranger_info", { user_id: userId });
58
+ const data = res?.data || res;
59
+ const nickname = String(data?.nickname ?? "").trim();
60
+ return nickname || null;
61
+ } catch (error: any) {
62
+ logger.warn(
63
+ `stranger-info: bot ${bot?.uin || bot?.bot_id || "?"} 查询 ${userId} 失败: ${error?.message || error}`,
64
+ );
65
+ return null;
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+
71
+ async function lookupNickname(userId: string): Promise<string | null> {
72
+ const cached = getCached(userId);
73
+ if (cached !== undefined) return cached;
74
+
75
+ const pending = inflight.get(userId);
76
+ if (pending) return pending;
77
+
78
+ const promise = (async () => {
79
+ const bots = getConnectedBots();
80
+ if (bots.length === 0) {
81
+ return null;
82
+ }
83
+ const results = await Promise.all(
84
+ bots.map((bot) => fetchNicknameFromBot(bot, userId)),
85
+ );
86
+ for (const nickname of results) {
87
+ if (nickname) {
88
+ setCached(userId, nickname);
89
+ return nickname;
90
+ }
91
+ }
92
+ setCached(userId, null);
93
+ return null;
94
+ })();
95
+
96
+ inflight.set(userId, promise);
97
+ try {
98
+ return await promise;
99
+ } finally {
100
+ inflight.delete(userId);
101
+ }
102
+ }
103
+
104
+ export function createStrangerRoutes() {
105
+ const app = new Hono();
106
+
107
+ app.post("/info", async (c) => {
108
+ const body = await c.req.json().catch(() => ({}));
109
+ const userId = normalizeUserId(body?.user_id);
110
+ if (!isValidQQNumber(userId)) {
111
+ return c.json(
112
+ { ok: false, error: "INVALID_USER_ID" } satisfies StrangerInfoResponse,
113
+ 400,
114
+ );
115
+ }
116
+
117
+ try {
118
+ const nickname = await lookupNickname(userId);
119
+ if (!nickname) {
120
+ return c.json(
121
+ {
122
+ ok: false,
123
+ error: "NICKNAME_NOT_FOUND",
124
+ } satisfies StrangerInfoResponse,
125
+ 404,
126
+ );
127
+ }
128
+ return c.json({
129
+ ok: true,
130
+ data: { userId, nickname },
131
+ } satisfies StrangerInfoResponse);
132
+ } catch (error: any) {
133
+ logger.error(`stranger-info 失败: ${error?.message || error}`);
134
+ return c.json(
135
+ {
136
+ ok: false,
137
+ error: "INTERNAL_ERROR",
138
+ } satisfies StrangerInfoResponse,
139
+ 500,
140
+ );
141
+ }
142
+ });
143
+
144
+ return app;
145
+ }
package/system.ts CHANGED
@@ -322,6 +322,26 @@ function readPackageJson(dir: string): any {
322
322
  }
323
323
  }
324
324
 
325
+ const WEBUI_PLUGIN_PREFIX = "mioku-plugin-";
326
+
327
+ function appendToMiokiPlugins(cwd: string, pkgName: string): boolean {
328
+ if (!pkgName.startsWith(WEBUI_PLUGIN_PREFIX)) return false;
329
+ const shortName = pkgName.slice(WEBUI_PLUGIN_PREFIX.length);
330
+
331
+ const packageJsonPath = path.join(cwd, "package.json");
332
+ if (!fs.existsSync(packageJsonPath)) return false;
333
+
334
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
335
+ const mioki = pkg.mioki ?? {};
336
+ const plugins = Array.isArray(mioki.plugins) ? [...mioki.plugins] : [];
337
+ if (plugins.includes(shortName)) return false;
338
+
339
+ plugins.push(shortName);
340
+ pkg.mioki = { ...mioki, plugins };
341
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
342
+ return true;
343
+ }
344
+
325
345
  function readRootPackageJson(): any {
326
346
  return JSON.parse(fs.readFileSync(ROOT_PACKAGE_PATH, "utf-8"));
327
347
  }
@@ -1020,12 +1040,18 @@ export async function installManagedPackage(
1020
1040
  makeManagedUpdateCacheKey(input.target, installedName),
1021
1041
  );
1022
1042
 
1043
+ let enabled = false;
1044
+ if (input.target === "plugin") {
1045
+ enabled = appendToMiokiPlugins(installDir, installedName);
1046
+ }
1047
+
1023
1048
  return {
1024
1049
  ok: true,
1025
1050
  name: installedName,
1026
1051
  missingServices,
1027
1052
  packageManager: "bun",
1028
1053
  restartRequired: true,
1054
+ enabled,
1029
1055
  installOutput: install.stdout || install.stderr,
1030
1056
  };
1031
1057
  }