diff --git a/.changeset/workspace-september-updates.md b/.changeset/workspace-september-updates.md new file mode 100644 index 00000000..25cb42b2 --- /dev/null +++ b/.changeset/workspace-september-updates.md @@ -0,0 +1,9 @@ +--- +"@sfmc-bds/sdk": patch +"@sfmc-bds/cli": patch +"@sfmc-bds/bds-tools": patch +"@sfmc-bds/db-server": patch +"@sfmc-bds/qq-bridge": patch +--- + +完善聊天频道来源与 QQ 消息转发、提交后事件通知和模块伴生服务加载;改进命令菜单与资源包展示,并支持安全注销系统消息处理器。 diff --git a/_test_archive/cli/command-palette.test.mjs b/_test_archive/cli/command-palette.test.mjs index 1155f99e..39d8c75c 100644 --- a/_test_archive/cli/command-palette.test.mjs +++ b/_test_archive/cli/command-palette.test.mjs @@ -15,7 +15,7 @@ import { resolvePaletteView, } from "../../packages/cli/dist/command-palette.js"; import { visibleWidth } from "../../packages/cli/dist/logs.js"; -import { listPaletteRoots } from "../../packages/cli/dist/command-surface.js"; +import { listNextPaletteTokens, listPaletteRoots } from "../../packages/cli/dist/command-surface.js"; describe("clipPad 等宽", () => { it("垫到 PANEL_WIDTH", () => { @@ -80,6 +80,46 @@ describe("resolvePaletteView", () => { it("叶命令 /start bds 到末尾立即关闭", () => { assert.equal(resolvePaletteView("/start bds", [0], [0]).hidden, true); }); + it("/ui 展开 studio 与 stop", () => { + const v = resolvePaletteView("/ui ", [0], [0]); + assert.equal(v.hidden, false); + const tokens = v.columns.at(-1).items.map((n) => n.token); + assert.deepEqual(tokens, ["studio", "stop"]); + }); + it("/packs doctor 展开实验性开关", () => { + const v = resolvePaletteView("/packs doctor ", [0], [0]); + assert.equal(v.hidden, false); + const tokens = v.columns.at(-1).items.map((n) => n.token); + assert.ok(tokens.includes("--fix")); + assert.ok(tokens.includes("--all-experiments")); + assert.ok(tokens.includes("--experiments=")); + assert.ok(tokens.includes("")); + }); + it("doctor 直接运行提交无参命令", () => { + const v = resolvePaletteView("/packs doctor ", [0], [0]); + const col = v.columns.at(-1); + const idx = col.items.findIndex((n) => n.token === ""); + const view = resolvePaletteView("/packs doctor ", [0, 0, idx], [0, 0, 0]); + const acc = commitSelection("/packs doctor ", view); + assert.equal(acc.line, "/packs doctor"); + assert.equal(acc.submit, true); + }); + it("--experiments= 粘在同一 token 上且随后收起面板", () => { + const v0 = resolvePaletteView("/packs doctor ", [0], [0]); + const idx = v0.columns.at(-1).items.findIndex((n) => n.token === "--experiments="); + const view = resolvePaletteView("/packs doctor ", [0, 0, idx], [0, 0, 0]); + const acc = commitSelection("/packs doctor ", view); + assert.equal(acc.line, "/packs doctor --experiments="); + assert.equal(acc.submit, false); + assert.equal(resolvePaletteView("/packs doctor --experiments=beta,upcoming", [0], [0]).hidden, true); + }); + it("灰字补全与面板共用 ui / doctor 的下一参", () => { + assert.deepEqual(listNextPaletteTokens(["ui"]), ["studio", "stop"]); + const doctor = listNextPaletteTokens(["packs", "doctor"]); + assert.ok(doctor?.includes("--all-experiments")); + assert.ok(doctor?.includes("--fix")); + assert.equal(doctor?.includes(""), false); + }); it("未到末尾 /start 仍显示", () => { assert.equal(resolvePaletteView("/start", [0], [0]).hidden, false); assert.ok(resolvePaletteView("/start", [0], [0]).columns.length >= 1); diff --git a/modules/sdk/@sfmc-sdk/CHANGELOG.md b/modules/sdk/@sfmc-sdk/CHANGELOG.md index 6246d374..e23ac8d3 100644 --- a/modules/sdk/@sfmc-sdk/CHANGELOG.md +++ b/modules/sdk/@sfmc-sdk/CHANGELOG.md @@ -164,7 +164,7 @@ - efa6e73: 将 Minecraft Script API 类型与新模块模板更新到 BDS 1.26.51 使用的 server 2.11.0-beta、server-ui 2.3.0-beta,并同步 vanilla-data 版本。 - aadd2de: 将官方 QQ Webhook 的 SSH 反向隧道纳入 SFMC CLI 服务编排,提供配置、启停、断线重试、状态和日志。 - efa6e73: 官方 QQ 机器人可选择 Webhook 接收事件;官方与 LLBot 配置改为分组对象。Webhook 模式自动跳过游戏机的桥进程,并安全同步共享配置到云端。群全量事件中的 `<@…>` 机器人提及也能正确触发指令面板命令。官方回复不再附带 LLBot 风格的编号和“发送:”列表。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - efa6e73: 为 QQ 管理菜单增加可即时生效的服务器事件推送开关,并补充 BDS 正常停服通知。 - 1a4ddda: dropdown 的 options 支持从 load 数据源注入(source/as/label/value),与 each 对齐。 - 1a4ddda: 声明式 UI 危险确认支持可选校验文本 challenge(固定字或 {{path}}),未匹配前确认按钮禁用。 diff --git a/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json b/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json index f6c8abef..d453f637 100644 --- a/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/pack_sources.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/schemas/pack_sources.schema.json", "title": "SFMC pack-sources", - "description": "世界包远端源绑定清单,位于 packs/pack-sources.json(非 configs/)。键为行为包 UUID(小写);由探测安装 / `packs bind` / 更新流程写入。", + "description": "资源包远端源绑定清单,位于 packs/pack-sources.json(非 configs/)。键为行为包 UUID(小写);由探测安装 / `packs bind` / 更新流程写入。", "type": "object", "additionalProperties": false, "properties": { diff --git a/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json b/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json index c2b8d601..5f903d0d 100644 --- a/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/pack_update.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/schemas/pack_update.schema.json", "title": "SFMC pack-update", - "description": "世界包(addons)自动检查/更新。apiKey 来自 https://console.curseforge.com/(x-api-key);也可用环境变量 CURSEFORGE_API_KEY。Bedrock gameId=78022,Addons classId=4984。官方 /mods/search 若 403 会改用 searchBaseUrl。match 为源无关策略,置于顶层。defaultBindingEnabled=false 时新建绑定默认关闭。", + "description": "资源包(addons)自动检查/更新。apiKey 来自 https://console.curseforge.com/(x-api-key);也可用环境变量 CURSEFORGE_API_KEY。Bedrock gameId=78022,Addons classId=4984。官方 /mods/search 若 403 会改用 searchBaseUrl。match 为源无关策略,置于顶层。defaultBindingEnabled=false 时新建绑定默认关闭。", "type": "object", "additionalProperties": false, "properties": { @@ -28,7 +28,7 @@ }, "probeSourceAfterInstall": { "type": "boolean", - "description": "安装世界包成功后是否探测 CurseForge 等远端源并尝试绑定" + "description": "安装资源包成功后是否探测 CurseForge 等远端源并尝试绑定" }, "defaultBindingEnabled": { "type": "boolean", @@ -160,7 +160,7 @@ "properties": { "recycleBin": { "type": "boolean", - "description": "true=移入 trashRelativeDir;false=直接删除世界包目录。可用 CLI --purge 强制删除" + "description": "true=移入 trashRelativeDir;false=直接删除资源包目录。可用 CLI --purge 强制删除" }, "trashRelativeDir": { "type": "string", diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts index 71fb2f65..125f8d26 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/menu-navigator.ts @@ -136,7 +136,6 @@ export class MenuNavigator { private player: Player; private form: CustomForm | null = null; private titleObs: ObservableString | null = null; - private backVis: ObservableBoolean | null = null; /** 跨页面共享的状态对象。 */ state: Record = {}; private _confirmIdx = 0; @@ -146,9 +145,8 @@ export class MenuNavigator { constructor(player: Player) { this.player = player; - if (ObservableStringCtor && ObservableBooleanCtor) { + if (ObservableStringCtor) { this.titleObs = new ObservableStringCtor(""); - this.backVis = new ObservableBooleanCtor(false); } } @@ -157,7 +155,7 @@ export class MenuNavigator { return this.history; } - /** 原生「回到上一级」弹栈后回调,供声明式运行时同步 session。 */ + /** `back()` 弹栈后回调,供声明式运行时同步 session。 */ setOnBack(handler: () => void): this { this.onBack = handler; return this; @@ -177,7 +175,6 @@ export class MenuNavigator { const token = ++this.sessionToken; this.history = [sectionId]; this.applySection(sectionId); - this.backVis?.setData(false); await this.buildAndShow(token); } @@ -367,11 +364,10 @@ export class MenuNavigator { private async buildAndShow(token = this.sessionToken): Promise { if (token !== this.sessionToken) return; if (this.form?.isShowing()) this.form.close(); - if (!CustomFormCtor || !this.titleObs || !this.backVis) { + if (!CustomFormCtor || !this.titleObs || !ObservableBooleanCtor) { throw new Error("当前环境下的 @minecraft/server-ui 不支持 CustomForm (DDUI),请在支持 DDUI 的 BDS 版本运行"); } this.form = new CustomFormCtor(this.player, this.titleObs); - this.form.button("← 回到上一级", () => this.back(), { visible: this.backVis }); for (const [id, def] of this.sections) { if (token !== this.sessionToken) return; const vis = this.sectionVis.get(id); @@ -408,7 +404,6 @@ export class MenuNavigator { private applySection(sectionId: string): void { for (const [id, vis] of this.sectionVis) vis.setData(id === sectionId); - this.backVis?.setData(this.history.length > 1); this.updateTitle(); } diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts index 5ad34e7d..3e805504 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/msg.ts @@ -12,8 +12,12 @@ let _systemMsgHandler: ((player: Player, text: string) => void) | null = null; * * @param handler 接收目标玩家与消息文本的转发回调函数。 */ -export function registerSystemMsgHandler(handler: (player: Player, text: string) => void): void { +export function registerSystemMsgHandler(handler: (player: Player, text: string) => void): () => void { _systemMsgHandler = handler; + const registered = handler; + return () => { + if (_systemMsgHandler === registered) _systemMsgHandler = null; + }; } /** @@ -82,4 +86,3 @@ export const Msg = { _systemMsgHandler?.(player, msg); }, }; - diff --git a/packages/bds-tools/CHANGELOG.md b/packages/bds-tools/CHANGELOG.md index 02b965ea..f8306670 100644 --- a/packages/bds-tools/CHANGELOG.md +++ b/packages/bds-tools/CHANGELOG.md @@ -338,7 +338,7 @@ ### Patch Changes -- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。世界包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 +- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。资源包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 - 5ada90e: 修复 #80/#81 合并后 `scanDestOccupancy` 未赋值 `facts`、引用未声明标识符导致 tsc 构建失败;恢复经 `readPackDirOccupancy` 的 DRY 占用扫描。 ## 0.2.0-beta.1 diff --git a/packages/bds-tools/src/pack-update/bindings.ts b/packages/bds-tools/src/pack-update/bindings.ts index ac79366b..abb4fff3 100644 --- a/packages/bds-tools/src/pack-update/bindings.ts +++ b/packages/bds-tools/src/pack-update/bindings.ts @@ -1,5 +1,5 @@ /** - * packs/pack-sources.json — 世界包远程源绑定 + * packs/pack-sources.json — 资源包远程源绑定 */ import { withConfigSchema } from "@sfmc-bds/sdk/node/config"; import fs from "node:fs"; diff --git a/packages/bds-tools/src/pack-update/types.ts b/packages/bds-tools/src/pack-update/types.ts index 315a15c9..d47d2b78 100644 --- a/packages/bds-tools/src/pack-update/types.ts +++ b/packages/bds-tools/src/pack-update/types.ts @@ -50,7 +50,7 @@ export interface StartupUpdateConfig { failMode: "continue" | "abort"; } -/** 世界包卸载策略(与 CF 更新同属 packs 生命周期配置) */ +/** 资源包卸载策略(与 CF 更新同属 packs 生命周期配置) */ export interface PackUninstallConfig { /** true:移入回收站;false:直接删除目录 */ recycleBin: boolean; diff --git a/packages/bds-tools/src/world-packs.ts b/packages/bds-tools/src/world-packs.ts index 5320f6d4..65cdff60 100644 --- a/packages/bds-tools/src/world-packs.ts +++ b/packages/bds-tools/src/world-packs.ts @@ -194,7 +194,10 @@ export function resolvePackLocalizedText(packDir: string, value: string): string for (const file of languageFiles) { if (!fs.existsSync(file)) continue; try { - const lines = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split(/\r?\n/); + const lines = fs + .readFileSync(file, "utf8") + .replace(/^\uFEFF/, "") + .split(/\r?\n/); for (const line of lines) { const trimmed = line.trimStart(); if (!trimmed || trimmed.startsWith("#")) continue; @@ -231,9 +234,7 @@ export function readPackManifestInfo(packDir: string): PackManifestInfo | null { uuid, version, kind, - ...(typeof description === "string" - ? { description: resolvePackLocalizedText(packDir, description) } - : {}), + ...(typeof description === "string" ? { description: resolvePackLocalizedText(packDir, description) } : {}), }; } catch { return null; @@ -853,7 +854,7 @@ export type UninstallPackResult = { action: "trashed"; dest: string } | { action export type UninstallPackAction = UninstallPackResult["action"]; /** - * 卸载已安装世界包:先 disable enable-list,再移入 trashDir 或直接删除目录。 + * 卸载已安装资源包:先 disable enable-list,再移入 trashDir 或直接删除目录。 * trashDir 跨盘符时 fallback 为 copy + rm。 */ export async function uninstallInstalledPack(opts: { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index e9de0020..295ff7b2 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -316,7 +316,7 @@ - efa6e73: QQ 查服改用游戏实时在线玩家及世界信息,CLI 托管的 BDS 启停和异常退出现在上报事件。 - aadd2de: 将官方 QQ Webhook 的 SSH 反向隧道纳入 SFMC CLI 服务编排,提供配置、启停、断线重试、状态和日志。 - efa6e73: 官方 QQ 机器人可选择 Webhook 接收事件;官方与 LLBot 配置改为分组对象。Webhook 模式自动跳过游戏机的桥进程,并安全同步共享配置到云端。群全量事件中的 `<@…>` 机器人提及也能正确触发指令面板命令。官方回复不再附带 LLBot 风格的编号和“发送:”列表。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - 4a9b066: 统一 QQ 双后端玩家服务菜单、权限核验、敏感操作确认及错误反馈,修复编号菜单与长名单发送,保留查服、版本和 ip 指令习惯。 新增 public_server 公开连接配置;版本优先使用 CLI 捕获并经状态接口核验的当前 BDS 启动版本。移除在线状态接口的 64 人上限,不改变入服业务状态与审批策略。 diff --git a/packages/cli/src/command-palette.ts b/packages/cli/src/command-palette.ts index f0b4fd4c..d259e7a5 100644 --- a/packages/cli/src/command-palette.ts +++ b/packages/cli/src/command-palette.ts @@ -255,6 +255,15 @@ function findChild(parent: PaletteNode | undefined, token: string): PaletteNode return parent?.children?.find((n) => n.token === token); } +/** + * 当前 token 是否落在「`=` 结尾」的自由参数上(如 `--experiments=beta,upcoming`)。 + * 这类值与前缀是同一个 token,面板应在写完前缀后收起。 + */ +function matchesGluedFreeArg(parent: PaletteNode | undefined, token: string): boolean { + if (!parent?.children?.length || !token) return false; + return parent.children.some((n) => n.freeArgs && n.token.endsWith("=") && token.startsWith(n.token)); +} + /** * 是否已进入自由参数尾部(如 /send bds …)。 * 路径落在 freeArgs 叶上,或叶之后还有无法匹配的多余 token,均视为自由输入。 @@ -284,7 +293,7 @@ export function isCommandAtEnd(committed: string[], partial: string, trailingSpa let node: PaletteNode | undefined; for (let i = 0; i < path.length; i++) { const next = i === 0 ? findRoot(path[i]!) : findChild(node, path[i]!); - if (!next) return false; + if (!next) return matchesGluedFreeArg(node, path[i]!); node = next; } if (!node || (node.children?.length ?? 0) > 0) return false; @@ -444,6 +453,12 @@ export function commitSelection(line: string, view: PaletteView): { line: string const { committed, partial, trailingSpace, active } = view; + /* 空 token = 直接运行父命令,不把占位标签写进命令行 */ + if (node.token === "") { + const base = committed.slice(0, active); + return { line: base.length ? `/${base.join(" ")}` : "/", submit: true }; + } + /* 叶节点且输入已完整匹配 → 提交执行 */ const isLeaf = !node.children?.length && !node.freeArgs; if (isLeaf && !trailingSpace) { @@ -458,7 +473,9 @@ export function commitSelection(line: string, view: PaletteView): { line: string const tokens = committed.slice(0, active); tokens.push(node.token); let next = "/" + tokens.join(" "); - if (node.children?.length || node.freeArgs) next += " "; + /* `=` 结尾的自由参数不补空格,避免把 `--experiments=beta` 拆成两个 token */ + const glue = node.freeArgs && node.token.endsWith("="); + if ((node.children?.length || node.freeArgs) && !glue) next += " "; return { line: next, submit: false }; } diff --git a/packages/cli/src/command-surface.ts b/packages/cli/src/command-surface.ts index d2f5928f..7bece27e 100644 --- a/packages/cli/src/command-surface.ts +++ b/packages/cli/src/command-surface.ts @@ -9,6 +9,7 @@ * 业务实现不在本文件 / 面板内:分发到 commands、module-commands、world-packs 等。 */ import { stdin } from "node:process"; +import { t } from "./i18n/index.js"; export type CommandChannel = "repl" | "both"; export type CommandMode = "argv" | "repl"; @@ -157,14 +158,20 @@ export function listVisiblePacksSubs(mode: CommandMode): string[] { export type PaletteNode = { /** 面板内显示的标签 */ label: string; - /** 追加到命令行的 token(根节点含或不含前导逻辑由组装负责) */ + /** + * 追加到命令行的 token。 + * 空字符串表示「直接运行」:不追加参数,回车提交已经写好的父命令。 + */ token: string; /** i18n 描述键 */ descKey?: string; accent?: CommandAccent; /** 固定下一参选项;回车后在右侧展开 */ children?: PaletteNode[]; - /** 无 children 时仍需自由输入(回车后填入空格继续打字) */ + /** + * 无 children 时仍需自由输入(回车后填入空格继续打字)。 + * token 以 `=` 结尾时不补空格,后续字符粘在同一 token 上(如 `--experiments=beta,upcoming`)。 + */ freeArgs?: boolean; }; @@ -245,14 +252,120 @@ function moduleChildNodes(mode: CommandMode): PaletteNode[] { }); } +/** + * 「直接运行」项:token 为空,回车提交已经写好的父命令。 + * 用在无参本身就是合法命令、同时还要展开可选开关的节点上。 + */ +function asIsNode(descKey: string, labelKey: "palette.asIs" | "palette.fixOnly" | "palette.localeCurrent" = "palette.asIs"): PaletteNode { + return { label: t(labelKey), token: "", descKey }; +} + +/** `/ui` 的固定子命令(studio 启动编辑器,stop 停掉当前进程内的服务)。 */ +function uiChildNodes(): PaletteNode[] { + return [ + { label: "studio", token: "studio", descKey: "help.ui.studio" }, + { label: "stop", token: "stop", descKey: "help.ui.stop" }, + ]; +} + +/** `/debug` 的固定子命令;sentry on 之后还要粘上 `--dsn=`。 */ +function debugChildNodes(): PaletteNode[] { + return [ + { label: "status", token: "status", descKey: "help.debug.status" }, + { label: "enable", token: "enable", descKey: "help.debug.toggle" }, + { label: "disable", token: "disable", descKey: "help.debug.toggle" }, + { + label: "sentry", + token: "sentry", + descKey: "help.debug.sentry", + children: [ + { + label: "on", + token: "on", + descKey: "help.debug.sentry.on", + children: [{ label: "--dsn=", token: "--dsn=", descKey: "help.debug.sentry.on", freeArgs: true }], + }, + { label: "off", token: "off", descKey: "help.debug.sentry.off" }, + ], + }, + ]; +} + +/** `/daemon` 的固定子命令。无参等价于 status,面板里仍单列出来。 */ +function daemonChildNodes(): PaletteNode[] { + return [ + { label: "status", token: "status", descKey: "help.daemon.status" }, + { label: "stop", token: "stop", descKey: "help.daemon.stop" }, + ]; +} + +/** `/locale` 的可选语言;第一项不带参数,只打印当前语言。 */ +function localeChildNodes(): PaletteNode[] { + return [ + asIsNode("help.locale", "palette.localeCurrent"), + { label: "zh-CN", token: "zh-CN", descKey: "locale.opt.zh" }, + { label: "en", token: "en", descKey: "locale.opt.en" }, + ]; +} + +/** `/update` 的已知开关。无参就是检查并更新。 */ +function updateChildNodes(): PaletteNode[] { + return [ + asIsNode("help.update"), + { label: "--check-only", token: "--check-only", descKey: "help.update.checkOnly" }, + { label: "--force", token: "--force", descKey: "help.update.force" }, + { label: "--no-start", token: "--no-start", descKey: "help.update.noStart" }, + { label: "--channel=release", token: "--channel=release", descKey: "help.update.channelRelease" }, + { label: "--channel=preview", token: "--channel=preview", descKey: "help.update.channelPreview" }, + ]; +} + +/** + * `packs doctor` 的开关。 + * 无参只诊断;`--experiments=` 后面的别名粘在同一个 token 上。 + */ +function doctorNode(): PaletteNode { + const experimentList: PaletteNode = { + label: "--experiments=", + token: "--experiments=", + descKey: "help.packs.doctor.experimentsList", + freeArgs: true, + }; + const allExperiments: PaletteNode = { + label: "--all-experiments", + token: "--all-experiments", + descKey: "help.packs.doctor.allExperiments", + }; + return { + label: "doctor", + token: "doctor", + descKey: "help.packs.doctor", + children: [ + asIsNode("help.packs.doctor"), + { + label: "--fix", + token: "--fix", + descKey: "help.packs.doctor.fix", + children: [asIsNode("help.packs.doctor.fix", "palette.fixOnly"), allExperiments, experimentList], + }, + allExperiments, + { label: "--experiments", token: "--experiments", descKey: "help.packs.doctor.experiments" }, + experimentList, + ], + }; +} + function packsChildNodes(mode: CommandMode): PaletteNode[] { const noArg = new Set(["list", "path", "sources"]); - return listVisiblePacksSubs(mode).map((sub) => ({ - label: sub, - token: sub, - descKey: PACKS_DESC[sub] ?? "help.addon", - freeArgs: !noArg.has(sub), - })); + return listVisiblePacksSubs(mode).map((sub) => { + if (sub === "doctor") return doctorNode(); + return { + label: sub, + token: sub, + descKey: PACKS_DESC[sub] ?? "help.addon", + freeArgs: !noArg.has(sub), + }; + }); } /** 层级命令树(REPL 面板主列) */ @@ -285,6 +398,16 @@ export function listPaletteRoots(mode: CommandMode = "repl"): PaletteNode[] { token: n, freeArgs: true, })); + } else if (s.name === "ui") { + node.children = uiChildNodes(); + } else if (s.name === "debug") { + node.children = debugChildNodes(); + } else if (s.name === "daemon") { + node.children = daemonChildNodes(); + } else if (s.name === "locale") { + node.children = localeChildNodes(); + } else if (s.name === "update") { + node.children = updateChildNodes(); } else if (!NO_ARG_TOP.has(s.name)) { node.freeArgs = true; } @@ -315,6 +438,26 @@ export function listPaletteRoots(mode: CommandMode = "repl"): PaletteNode[] { return out; } +/** + * 已提交路径的下一参 token,供非 `/` 灰字补全与面板共用同一棵树。 + * 没有对应节点时返回 null;节点存在但没有子项时返回空数组。 + * 「直接运行」的空 token 不出现在补全里。 + */ +export function listNextPaletteTokens(path: readonly string[], mode: CommandMode = "repl"): string[] | null { + if (path.length === 0) return null; + let current: PaletteNode | undefined = listPaletteRoots(mode).find((n) => n.token === path[0]); + if (!current) return null; + for (let i = 1; i < path.length; i++) { + const token = path[i] ?? ""; + const children: readonly PaletteNode[] = current.children ?? []; + current = children.find((child) => child.token === token); + if (!current) return null; + } + const kids = current.children; + if (!kids?.length) return []; + return kids.map((child) => child.token).filter((token) => token.length > 0); +} + export function isDevAccent(spec: CommandSpec | undefined): boolean { return spec?.accent === "dev"; } diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 80c7ad89..ee75009c 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -12,12 +12,7 @@ import { clearBdsPidFile, killBedrockServerByImage, probeBdsStatus } from "@sfmc-bds/bds-tools/process-probe"; import { stripTaskbarOsc } from "@sfmc-bds/bds-tools/taskbar"; import { didUpdateDeploy } from "@sfmc-bds/bds-tools/update-result"; -import { - DEFAULT_QQ_CONFIG, - loadEnsuredConfig, - qqRuntimeStatusPath, - type QQBridgeConfig, -} from "@sfmc-bds/sdk/node/config"; +import { qqRuntimeStatusPath } from "@sfmc-bds/sdk/node/config"; import fs from "node:fs"; import { isDaemonServer } from "./daemon/role.js"; import { t } from "./i18n/index.js"; @@ -65,27 +60,6 @@ function statusLine( return ` ${dot} ${c.bold(padRight(name, 14))} ${padRight(state, 8)} ${padRight(owner, 6)} ${padRight(pidStr, 8)} ${upStr}`; } -/** status 页脚:QQ 后端与关键摘要(密钥脱敏) */ -function qqBridgeStatusFooter(): string { - const qqCfg = loadEnsuredConfig(ROOT, "qq_config.json", "qq_config", { ...DEFAULT_QQ_CONFIG } as Record< - string, - unknown - >) as QQBridgeConfig; - const backend = qqCfg.qq_backend === "llbot" ? "llbot" : "official"; - const enabled = qqCfg.qq_enabled !== false; - if (backend === "llbot") { - const group = qqCfg.llbot?.group_id || "—"; - const pathHint = qqCfg.llbot?.path ? String(qqCfg.llbot.path) : "—"; - return `\n${c.dim(t("svc.qq.footer.llbot", { enabled: enabled ? "on" : "off", group, path: pathHint }))}\n`; - } - const appId = String(qqCfg.official?.app_id ?? "").trim(); - const appIdHint = appId ? (appId.length > 8 ? `${appId.slice(0, 4)}…${appId.slice(-4)}` : appId) : "—"; - const openid = String(qqCfg.official?.group_openid ?? "").trim() || "—"; - const sandbox = qqCfg.official?.sandbox ? "sandbox" : "prod"; - const creds = appId && String(qqCfg.official?.app_secret ?? "").trim() ? "ok" : "missing"; - return `\n${c.dim(t("svc.qq.footer.official", { enabled: enabled ? "on" : "off", appId: appIdHint, openid, sandbox, creds }))}\n`; -} - /** * 查询并格式化展示所有后台服务的当前运行状态矩阵。 * @@ -101,9 +75,7 @@ export async function cmdStatus(): Promise { const pidH = t("svc.col.pid"); const upH = t("svc.col.uptime"); const header = ` ${padRight(nameH, 16)}${padRight(statusH, 8)}${padRight(ownerH, 6)}${padRight(pidH, 8)}${upH}`; - return ( - `\n${c.bold(t("svc.header"))}\n` + c.dim(header) + "\n" + DIVIDER + "\n" + lines.join("\n") + qqBridgeStatusFooter() - ); + return `\n${c.bold(t("svc.header"))}\n` + c.dim(header) + "\n" + DIVIDER + "\n" + lines.join("\n"); }); } diff --git a/packages/cli/src/i18n/locales/en.ts b/packages/cli/src/i18n/locales/en.ts index 71da846d..ea528392 100644 --- a/packages/cli/src/i18n/locales/en.ts +++ b/packages/cli/src/i18n/locales/en.ts @@ -70,12 +70,20 @@ export const en = { "help.send": "Send command to a service (Tab cycles targets; QQ prompt shows official / llbot)", "help.section.update": "─── BDS Update ───", "help.update": "Check / apply BDS update", + "help.update.checkOnly": "Check for an update without downloading", + "help.update.force": "Force update even when versions match", + "help.update.noStart": "Do not start BDS after the update", + "help.update.channelRelease": "Use the release channel", + "help.update.channelPreview": "Use the preview channel", "help.section.debug": "─── BDS debug ───", "help.debug.status": "View sfmc_debug / SENTRY_DSN state", "help.debug.toggle": "Toggle sfmc_debug", "help.debug.sentry.on": "Set SENTRY_DSN", "help.debug.sentry.off": "Remove SENTRY_DSN", + "help.debug.sentry": "Configure Sentry DSN", "help.ui": "UI Studio — visual editor for declarative UI", + "help.ui.studio": "Start UI Studio and open the browser", + "help.ui.stop": "Stop UI Studio in this process", "ui.usage": "Usage: ui studio | ui stop", "ui.studio.started": "UI Studio started: {url}", "ui.studio.hint": "Press Ctrl+C to stop the server.", @@ -100,6 +108,11 @@ export const en = { "help.packs.enable": "Enable a pack", "help.packs.disable": "Disable a pack", "help.packs.doctor": "Diagnose pack wiring", + "help.packs.doctor.fix": "Heal wiring and grant missing Beta APIs", + "help.packs.doctor.allExperiments": "Enable all known experimental gameplay", + "help.packs.doctor.experiments": "Show experiment toggles only", + "help.packs.doctor.experimentsList": + "Enable by alias, comma-separated: beta, upcoming, cameras, voxel, villager, drop3, edu", "help.packs.path": "Show pack paths", "help.section.general": "─── General ───", "help.init": "Setup wizard", @@ -141,6 +154,11 @@ export const en = { "daemon.usage": "usage: daemon [status|stop]", "daemon.connectFailed": "failed to connect daemon: {message}", "help.daemon": "Show/stop background daemon", + "help.daemon.status": "Show whether the daemon is running", + "help.daemon.stop": "Stop the background daemon", + "palette.asIs": "run", + "palette.fixOnly": "fix only", + "palette.localeCurrent": "current", /* ─── services / commands ─── */ "svc.header": "Services", @@ -187,9 +205,6 @@ export const en = { "svc.startAll.skipped": "Skipped: {list}", "svc.startAll.failed": "Failed: {list}", "svc.startAll.empty": "Nothing to start", - "svc.qq.footer.official": - "QQ bridge: official · {enabled} · AppID {appId} · group_openid {openid} · {sandbox} · creds={creds}", - "svc.qq.footer.llbot": "QQ bridge: llbot · {enabled} · group {group} · path {path}", "svc.updateComplete": "update complete", "svc.updateExited": "update exited with code {code}", "svc.updateError": "update error: {message}", diff --git a/packages/cli/src/i18n/locales/zh-CN.ts b/packages/cli/src/i18n/locales/zh-CN.ts index 5bedcbc4..e4b52b0a 100644 --- a/packages/cli/src/i18n/locales/zh-CN.ts +++ b/packages/cli/src/i18n/locales/zh-CN.ts @@ -67,12 +67,20 @@ export const zhCN: Record = { "help.send": "向服务发送命令(Tab 切换目标;QQ 提示符区分 official / llbot)", "help.section.update": " BDS 更新 ──────", "help.update": "检查BDS 更新", + "help.update.checkOnly": "只检查更新,不下载", + "help.update.force": "即使版本相同也强制更新", + "help.update.noStart": "更新后不启动 BDS", + "help.update.channelRelease": "使用正式版通道", + "help.update.channelPreview": "使用预览版通道", "help.section.debug": " 调试 ──────", "help.debug.status": "SFMC_DEBUG 状态", "help.debug.toggle": "开关 SFMC_DEBUG", "help.debug.sentry.on": "设置 SENTRY_DSN", "help.debug.sentry.off": "删除 SENTRY_DSN", + "help.debug.sentry": "配置 Sentry DSN", "help.ui": "UI Studio 声明式界面可视化编辑器", + "help.ui.studio": "启动 UI Studio 并打开浏览器", + "help.ui.stop": "停止当前进程里的 UI Studio", "ui.usage": "用法: ui studio | ui stop", "ui.studio.started": "UI Studio 已启动: {url}", "ui.studio.hint": "按 Ctrl+C 停止服务。", @@ -97,6 +105,11 @@ export const zhCN: Record = { "help.packs.enable": "启用资源包", "help.packs.disable": "禁用资源包", "help.packs.doctor": "诊断资源包接线", + "help.packs.doctor.fix": "修复接线并补齐缺失的测试版 API", + "help.packs.doctor.allExperiments": "开启全部已知实验性玩法", + "help.packs.doctor.experiments": "仅查看实验性开关状态", + "help.packs.doctor.experimentsList": + "按别名开启,逗号分隔:beta, upcoming, cameras, voxel, villager, drop3, edu", "help.packs.path": "显示资源包路径", "help.section.general": " 通用 ──────", "help.init": "初始化向导", @@ -124,6 +137,11 @@ export const zhCN: Record = { "daemon.usage": "用法: daemon [status|stop]", "daemon.connectFailed": "无法连接守护进程: {message}", "help.daemon": "查看/停止后台守护进程", + "help.daemon.status": "查看守护进程是否在运行", + "help.daemon.stop": "停止后台守护进程", + "palette.asIs": "直接运行", + "palette.fixOnly": "仅修复", + "palette.localeCurrent": "当前语言", "repl.historyHeader": "── 历史 {count} 行 ──", "repl.filter": "过滤: {level} / {source} / {history}", "repl.followRequiresTty": "跟踪模式需要 TTY", @@ -184,9 +202,6 @@ export const zhCN: Record = { "svc.startAll.skipped": "已跳过: {list}", "svc.startAll.failed": "启动失败: {list}", "svc.startAll.empty": "没有服务需要启动", - "svc.qq.footer.official": - "QQ 桥: official · {enabled} · AppID {appId} · group_openid {openid} · {sandbox} · creds={creds}", - "svc.qq.footer.llbot": "QQ 桥: llbot · {enabled} · 群 {group} · path {path}", "svc.updateComplete": "更新完成", "svc.updateExited": "更新进程退出,代码 {code}", "svc.updateError": "更新错误: {message}", diff --git a/packages/cli/src/module-commands.ts b/packages/cli/src/module-commands.ts index 454ed5ad..5997288a 100644 --- a/packages/cli/src/module-commands.ts +++ b/packages/cli/src/module-commands.ts @@ -28,7 +28,7 @@ import { failResult, okResult, type CliResult } from "./cli-result.js"; import { t } from "./i18n/index.js"; import { c } from "./theme.js"; import { ROOT, resolveFetchModule } from "./runtime.js"; -import { dirFingerprint } from "./module-fingerprint.js"; +import { dirFingerprint, shouldSkipModuleEntry } from "./module-fingerprint.js"; import { applyLockEnabled, finalizeToggle, @@ -130,9 +130,14 @@ async function scanInstalled(): Promise { /* corrupt manifest is not fatal — just skip its fields */ } } - const { totalBytes, fileCount } = await dirSize(modPath); - const fingerprint = await dirFingerprint(modPath); - out.push({ id, path: modPath, manifest, totalBytes, fileCount, fingerprint }); + try { + const { totalBytes, fileCount } = await dirSize(modPath); + const fingerprint = await dirFingerprint(modPath); + out.push({ id, path: modPath, manifest, totalBytes, fileCount, fingerprint }); + } catch { + // 坏链接或目录已消失时跳过,不让 CLI 起不来 + continue; + } } out.sort((a, b) => a.id.localeCompare(b.id)); return out; @@ -143,7 +148,7 @@ async function dirSize(dir: string): Promise<{ totalBytes: number; fileCount: nu let fileCount = 0; async function walk(p: string): Promise { for (const e of await fs.readdir(p, { withFileTypes: true })) { - if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue; + if (shouldSkipModuleEntry(path.basename(p), e.name)) continue; const child = path.join(p, e.name); if (e.isDirectory()) await walk(child); else if (e.isFile()) { diff --git a/packages/cli/src/module-fingerprint.ts b/packages/cli/src/module-fingerprint.ts index dc34cb4f..ecf1d90c 100644 --- a/packages/cli/src/module-fingerprint.ts +++ b/packages/cli/src/module-fingerprint.ts @@ -7,6 +7,15 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +/** + * 模块目录扫描时要跳过的名字。 + * 使用场景:指纹和体积统计共用。map-server/data 是地图快照,扫进去会让 CLI 启动卡死。 + */ +export function shouldSkipModuleEntry(parentDirName: string, name: string): boolean { + if (name.startsWith(".") || name === "node_modules" || name === "dist") return true; + return name === "data" && parentDirName === "map-server"; +} + /** Compute the canonical SHA-256 of a module directory (POSIX `find ... | sha256sum` compatible). */ export async function dirFingerprint(rootDir: string): Promise { const hash = createHash("sha256"); @@ -16,7 +25,7 @@ export async function dirFingerprint(rootDir: string): Promise { const items = await fs.readdir(full, { withFileTypes: true }); const sorted = items.sort((a, b) => a.name.localeCompare(b.name)); for (const it of sorted) { - if (it.name.startsWith(".") || it.name === "node_modules" || it.name === "dist") continue; + if (shouldSkipModuleEntry(path.posix.basename(rel), it.name)) continue; const child = rel ? `${rel}/${it.name}` : it.name; if (it.isDirectory()) await walk(child); else if (it.isFile()) entries.push(child); @@ -25,7 +34,13 @@ export async function dirFingerprint(rootDir: string): Promise { await walk(""); entries.sort(); for (const rel of entries) { - const data = await fs.readFile(path.join(rootDir, rel)); + let data: Buffer; + try { + data = await fs.readFile(path.join(rootDir, rel)); + } catch { + // 单个文件打不开时跳过,避免整次启动失败 + continue; + } hash.update(rel.replaceAll("\\", "/")); hash.update("\n"); hash.update(data); diff --git a/packages/cli/src/module-sidecars.ts b/packages/cli/src/module-sidecars.ts new file mode 100644 index 00000000..68ee1ac2 --- /dev/null +++ b/packages/cli/src/module-sidecars.ts @@ -0,0 +1,152 @@ +/** + * 模块随 BDS 启动的附属进程。 + * 使用场景:开服前扫描 modules/packages//sidecar.json。 + * 目前 Bluemap 用它拉起地图进程;日志正文由脚本打进 BDS,这里只报告启动和退出。 + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; + +/** 模块包根目录里的 sidecar.json。 */ +interface SidecarSpec { + id: string; + /** 相对模块包根目录的 Node 入口。 */ + entry: string; + /** 相对 SFMC 根目录的配置文件。缺省不判断开关。 */ + configFile?: string; + /** 配置里的点路径。值为 false 时不启动。缺省视为开启。 */ + when?: string; +} + +/** 本轮 BDS 拉起的附属进程。停服时只结束这些,不碰用户另外开的进程。 */ +const children = new Map(); + +function readJson(file: string): Record | null { + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown; + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } +} + +/** 沿点路径取值。中途不是对象则返回 undefined。 */ +function readPath(source: Record, dotted: string): unknown { + let current: unknown = source; + for (const part of dotted.split(".")) { + if (!current || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} + +function isEnabled(root: string, spec: SidecarSpec): boolean { + if (!spec.when || !spec.configFile) return true; + const config = readJson(path.join(root, spec.configFile)); + if (!config) return true; + const flag = readPath(config, spec.when); + return flag !== false; +} + +function configuredPort(root: string, spec: SidecarSpec): number { + if (!spec.configFile) return 0; + const config = readJson(path.join(root, spec.configFile)); + const port = config?.port; + return typeof port === "number" && port > 0 && port < 65536 ? port : 0; +} + +/** 端口已有进程监听时不再启动第二个。 */ +function portOpen(port: number): Promise { + if (!port) return Promise.resolve(false); + return new Promise((resolve) => { + const socket = net.connect({ host: "127.0.0.1", port }); + const done = (open: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(open); + }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.setTimeout(500, () => done(false)); + }); +} + +function loadSpecs(root: string): Array<{ spec: SidecarSpec; dir: string }> { + const packagesDir = path.join(root, "modules", "packages"); + if (!fs.existsSync(packagesDir)) return []; + const found: Array<{ spec: SidecarSpec; dir: string }> = []; + for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const dir = path.join(packagesDir, entry.name); + const file = path.join(dir, "sidecar.json"); + if (!fs.existsSync(file)) continue; + const raw = readJson(file); + if (!raw || typeof raw.id !== "string" || typeof raw.entry !== "string") continue; + const lock = readJson(path.join(root, "modules", "module-lock.json")); + const modules = lock?.modules && typeof lock.modules === "object" ? (lock.modules as Record) : {}; + const row = modules[raw.id]; + const enabled = !row || typeof row !== "object" || (row as { enabled?: boolean }).enabled !== false; + if (!enabled) continue; + const spec: SidecarSpec = { id: raw.id, entry: raw.entry }; + if (typeof raw.configFile === "string") spec.configFile = raw.configFile; + if (typeof raw.when === "string") spec.when = raw.when; + found.push({ spec, dir }); + } + return found; +} + +/** + * 在 BDS 进程拉起之前启动附属进程。 + * 使用场景:services.ts 里 bds 的 start,在 beforeStart 之后调用。 + */ +export async function startModuleSidecars( + root: string, + log: (line: string, stream: "stdout" | "stderr") => void +): Promise { + for (const { spec, dir } of loadSpecs(root)) { + if (children.has(spec.id)) continue; + if (!isEnabled(root, spec)) continue; + const entry = path.join(dir, spec.entry); + if (!fs.existsSync(entry)) { + log(`[${spec.id}] 未找到附属进程入口: ${entry}`, "stderr"); + continue; + } + const port = configuredPort(root, spec); + if (await portOpen(port)) { + log(`[${spec.id}] 端口 ${port} 已在监听,跳过启动。`, "stdout"); + continue; + } + const child = spawn(process.execPath, [entry], { + cwd: dir, + env: { ...process.env, SFMC_ROOT: root }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + children.set(spec.id, child); + log(`[${spec.id}] 已随服务器启动附属进程 (PID ${child.pid ?? 0})`, "stdout"); + child.on("exit", (code) => { + if (children.get(spec.id) === child) children.delete(spec.id); + log(`[${spec.id}] 附属进程退出 (code ${code ?? "?"})`, code === 0 ? "stdout" : "stderr"); + }); + child.on("error", (err) => { + log(`[${spec.id}] 附属进程启动失败: ${err.message}`, "stderr"); + }); + } +} + +/** + * 结束本轮由开服拉起的附属进程。 + * 使用场景:BDS 服务 cleanup 时调用。用户自己另开的地图进程不会被关掉。 + */ +export function stopModuleSidecars(): void { + for (const [id, child] of children) { + children.delete(id); + try { + child.kill(); + } catch { + /* 进程已经结束时忽略。 */ + } + } +} diff --git a/packages/cli/src/repl.ts b/packages/cli/src/repl.ts index f4a74d3f..3b2617bb 100644 --- a/packages/cli/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -13,6 +13,7 @@ import { resolvePaletteView, } from "./command-palette.js"; import { + listNextPaletteTokens, listVisiblePacksSubs, listVisibleTopLevelNames, resolveModuleTopShorthand, @@ -343,6 +344,9 @@ function getCompletions(parsed: ParsedLine): string[] { const cmds = getCommands().map((n) => (n.startsWith("/") ? n : n)); return ["/", ...cmds].filter(sw); } + /* 面板树里已经有固定下一参时,灰字与面板共用同一份选项 */ + const paletteNext = listNextPaletteTokens([cmd, ...words.slice(0, argIndex)], REPL_MODE); + if (paletteNext && paletteNext.length > 0) return paletteNext.filter(sw); switch (cmd) { case "logs": case "log": @@ -356,7 +360,7 @@ function getCompletions(parsed: ParsedLine): string[] { if (argIndex === 0) return SERVICE_NAMES.filter(sw); return []; case "update": - return ["--check-only", "--force", "--channel=release", "--channel=preview"].filter(sw); + return ["--check-only", "--force", "--no-start", "--channel=release", "--channel=preview"].filter(sw); case "packs": case "addon": if (argIndex === 0) return [...listVisiblePacksSubs(REPL_MODE)].filter(sw); diff --git a/packages/cli/src/services.ts b/packages/cli/src/services.ts index f80fca18..97f2785f 100644 --- a/packages/cli/src/services.ts +++ b/packages/cli/src/services.ts @@ -48,6 +48,7 @@ import { findNodeServicePids } from "./node-service-probe.js"; import { ensurePackUpdateConfigFile } from "./pack-update/index.js"; import { recordBdsVersion } from "./bds-runtime-version.js"; import { reportBdsPlayerSession } from "./player-session.js"; +import { startModuleSidecars, stopModuleSidecars } from "./module-sidecars.js"; import { ROOT, spawnService, type ServiceId } from "./runtime.js"; export { ROOT } from "./runtime.js"; @@ -235,6 +236,9 @@ class Service { if (this.def.beforeStart) { await this.def.beforeStart(); } + if (this.name === "bds") { + await startModuleSidecars(ROOT, (line, stream) => this.pushLog(line, stream)); + } // beforeStart 含异步检查;更新可能在等待期间开始。 if (this.updateInProgress) throw new Error("BDS 正在更新,暂不可启动"); this.manualStop = false; @@ -418,6 +422,7 @@ class Service { if (filePid === exitingPid) { clearBdsPidFile(ROOT); } + stopModuleSidecars(); } if (this.name === "tunnel" && exitingPid > 0) clearTunnelRuntime(exitingPid); if (wasRunning) { diff --git a/packages/cli/src/world-packs.ts b/packages/cli/src/world-packs.ts index 04f402d9..4d324f67 100644 --- a/packages/cli/src/world-packs.ts +++ b/packages/cli/src/world-packs.ts @@ -72,7 +72,7 @@ function rejoinHint(): string { return t("packs.rejoinHint"); } -/** 世界包编排日志:统一走 sfmc 聚合层(source=pack) */ +/** 资源包编排日志:统一走 sfmc 聚合层(source=pack) */ function logPack(text: string, level: LogLevel = "info"): void { pushLog(text, "pack", level); } @@ -185,7 +185,7 @@ function formatUninstallOutcome(r: UninstallOneOutcome): string { } /** - * 卸载单个已装世界包(disable + 移回收站/删除)。 + * 卸载单个已装资源包(disable + 移回收站/删除)。 * 不负责配对 RP / 绑定 —— 由上层编排。返回结构化结果(DIP:展示与策略分离)。 */ async function uninstallOneInstalledPack( diff --git a/packages/create-module/CHANGELOG.md b/packages/create-module/CHANGELOG.md index 020922c9..5c9fb287 100644 --- a/packages/create-module/CHANGELOG.md +++ b/packages/create-module/CHANGELOG.md @@ -33,7 +33,7 @@ - 4a9b066: 模块脚手架不再生成常驻测试目录或测试命令,改以类型检查、构建和实际联调验收。 - efa6e73: 将 Minecraft Script API 类型与新模块模板更新到 BDS 1.26.51 使用的 server 2.11.0-beta、server-ui 2.3.0-beta,并同步 vanilla-data 版本。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 ## 0.1.0-beta.3 diff --git a/packages/db-server/CHANGELOG.md b/packages/db-server/CHANGELOG.md index 01641f5a..1b112e94 100644 --- a/packages/db-server/CHANGELOG.md +++ b/packages/db-server/CHANGELOG.md @@ -126,7 +126,7 @@ - aadd2de: Use each chat channel's prefix and QQ forwarding switch for MC messages. Route QQ group messages into the read-only game channel and remove the former bridge channel and MC prefix configuration fields. - efa6e73: QQ 查服改用游戏实时在线玩家及世界信息,CLI 托管的 BDS 启停和异常退出现在上报事件。 - efa6e73: 官方 QQ 机器人可选择 Webhook 接收事件;官方与 LLBot 配置改为分组对象。Webhook 模式自动跳过游戏机的桥进程,并安全同步共享配置到云端。群全量事件中的 `<@…>` 机器人提及也能正确触发指令面板命令。官方回复不再附带 LLBot 风格的编号和“发送:”列表。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - 4a9b066: 统一 QQ 双后端玩家服务菜单、权限核验、敏感操作确认及错误反馈,修复编号菜单与长名单发送,保留查服、版本和 ip 指令习惯。 新增 public_server 公开连接配置;版本优先使用 CLI 捕获并经状态接口核验的当前 BDS 启动版本。移除在线状态接口的 64 人上限,不改变入服业务状态与审批策略。 diff --git a/packages/db-server/src/domain/bridge.ts b/packages/db-server/src/domain/bridge.ts index d8112503..29833931 100644 --- a/packages/db-server/src/domain/bridge.ts +++ b/packages/db-server/src/domain/bridge.ts @@ -2,8 +2,8 @@ * domain/bridge.ts — MC → QQ 桥接(双后端) * * 数据流: - * SAPI ──POST──→ db-server:3001/api/sfmc/messages - * └─ 写库 + forwardToQQBridge() + * 游戏发言 ──db.tx 插入──→ afterCommit 按频道 forward_to_qq 调用 forwardToQQBridge() + * QQ 入站 ──POST──→ /api/sfmc/messages,写入消息来源为 QQ 的频道(不回推群) * ├─ llbot → LLBot:3004/send_group_msg * └─ official → OpenAPI /v2/groups/{openid}/messages * diff --git a/packages/db-server/src/domain/installed-content.ts b/packages/db-server/src/domain/installed-content.ts index 1af769af..3e9a4660 100644 --- a/packages/db-server/src/domain/installed-content.ts +++ b/packages/db-server/src/domain/installed-content.ts @@ -1,13 +1,13 @@ /** * domain/installed-content.ts — 只读汇总已装模块与世界行为包、资源包。 * - * 供 QQ「模块 / 世界包」查询。模块启停仍以 catalog + lock 为准; - * 世界包名单与显示名跟 CLI 同一条路径:listInstalledWorldPacks 会解析 pack.name 这类语言键。 + * 供 QQ「模块 / 资源包」查询。模块启停仍以 catalog + lock 为准; + * 资源包名单与显示名跟 CLI 同一条路径:listInstalledWorldPacks 会解析 pack.name 这类语言键。 */ +import { listInstalledWorldPacks } from "@sfmc-bds/bds-tools/world-packs"; import fs from "node:fs"; import path from "node:path"; -import { listInstalledWorldPacks } from "@sfmc-bds/bds-tools/world-packs"; /** 一条已安装模块。has_resource_pack 表示该模块目录下有 resource_pack。 */ export type ContentModule = { @@ -232,3 +232,4 @@ export function loadContentSnapshot(opts: { resource_packs: readEnabledWorldResourcePacks(opts.bdsRoot, levelName, packs), }; } + diff --git a/packages/db-server/src/index.ts b/packages/db-server/src/index.ts index dfb9c358..4ca59e5a 100644 --- a/packages/db-server/src/index.ts +++ b/packages/db-server/src/index.ts @@ -22,8 +22,8 @@ import http from "node:http"; import { SQL } from "sql-template-strings"; import { createPlatformTables } from "./db-tables.js"; -import { initSchema } from "./domain/schema.js"; import { syncPlayerSession } from "./domain/player-session.js"; +import { initSchema } from "./domain/schema.js"; import { loadEnv } from "./env.js"; import { createIdempotencyStore } from "./lib/idempotency-store.js"; import { log } from "./lib/log.js"; @@ -47,14 +47,14 @@ import { createModuleConfigRoutes } from "./routes/module-config-routes.js"; import { createServiceRoutes } from "./routes/service-routes.js"; import { forwardToQQBridge, makeOutboundConfig } from "./domain/bridge.js"; +import { loadContentSnapshot } from "./domain/installed-content.js"; import { createQqEventsAggregator, resolveQqEventsConfig, type ResolvedQqEventsConfig } from "./domain/qq-events.js"; import { body as sharedBody, json as sharedJson } from "./lib/http.js"; import { isEnabled, loadModuleLock, saveModuleLock, updateModuleState } from "./lib/module-state.js"; -import { loadContentSnapshot } from "./domain/installed-content.js"; import { createConfigRoutes } from "./routes/config.js"; import { createContentRoutes } from "./routes/content.js"; import { createHealthRoutes } from "./routes/health.js"; -import { createMessagesRoutes } from "./routes/messages.js"; +import { createMessagesRoutes, gameChatForwardPayload, type ChatChannelQQFlags } from "./routes/messages.js"; import { createModuleRoutes } from "./routes/modules.js"; import { createQqBindRoutes } from "./routes/qq-bind.js"; import { createQqEventsRoutes } from "./routes/qq-events.js"; @@ -184,7 +184,7 @@ function buildModuleList() { } /** - * QQ「模块 / 世界包」快照。每次请求现读 catalog 与磁盘,装包后不用重启 db-server。 + * QQ「模块 / 资源包」快照。每次请求现读 catalog 与磁盘,装包后不用重启 db-server。 */ function loadContentSnapshotForRequest() { const modules = (buildModuleList() ?? []).flatMap((raw) => { @@ -368,27 +368,60 @@ const qqEventsRoutes = createQqEventsRoutes({ isAdmin: (openid, asGroupAdmin) => { const admins = (env.qqconfig["official"] as { admin_openids?: unknown } | undefined)?.admin_openids; return ( - (Array.isArray(admins) && admins.includes(openid)) || - (asGroupAdmin && readJoinFlags().treatGroupAdminsAsAdmins) + (Array.isArray(admins) && admins.includes(openid)) || (asGroupAdmin && readJoinFlags().treatGroupAdminsAsAdmins) ); }, }); +/** 读取单个聊天频道。表尚未由聊天模块建出时返回 null,不让请求 500。 */ +function readChatChannel(channelId: string): ChatChannelQQFlags | null { + try { + const rows = query(SQL`SELECT * FROM sfmc_chat_channels WHERE id = ${channelId}`); + return Array.isArray(rows) ? ((rows[0] as ChatChannelQQFlags | undefined) ?? null) : null; + } catch (err) { + log.warn(`读取聊天频道失败: ${(err as Error).message}`); + return null; + } +} + +/** 消息来源为 QQ 的频道。QQ 入站只投到这些频道。 */ +function listQQSourceChannels(): ChatChannelQQFlags[] { + try { + const rows = query(SQL`SELECT * FROM sfmc_chat_channels`); + if (!Array.isArray(rows)) return []; + return (rows as ChatChannelQQFlags[]).filter((row) => row.source_qq === 1); + } catch (err) { + log.warn(`读取 QQ 来源频道失败: ${(err as Error).message}`); + return []; + } +} + const messagesRoutes = createMessagesRoutes({ query, body, json, - getChannelForQQ: (channelId: string) => { - const rows = query( - SQL`SELECT * FROM sfmc_chat_channels WHERE id = ${channelId}` - ); - return Array.isArray(rows) - ? (rows[0] as { prefix: string; forward_to_qq: number; source_game?: number; source_qq?: number; source_system?: number } | undefined) ?? null - : null; - }, + getChannelForQQ: readChatChannel, + listQQSourceChannels, forwardToQQBridge: (channelId: string, prefix: string, fromName: string, content: string, fromId: string) => forwardToQQBridge(currentOutbound(), channelId, prefix, fromName, content, fromId), }); + +// 游戏发言走 db.tx 插入,不经过 /api/sfmc/messages。提交后再按频道开关转发到 QQ。 +txRunner.setAfterCommit((inserts) => { + for (const item of inserts) { + if (item.table !== "sfmc_chat_messages") continue; + const payload = gameChatForwardPayload(item.row, readChatChannel(String(item.row.channel_id ?? ""))); + if (!payload) continue; + forwardToQQBridge( + currentOutbound(), + payload.channelId, + payload.prefix, + payload.fromName, + payload.content, + payload.fromId, + ); + } +}); const configRoutes = createConfigRoutes({ json, projectRoot: env.PROJECT_ROOT, diff --git a/packages/db-server/src/routes/messages.ts b/packages/db-server/src/routes/messages.ts index 319a21df..3ef742bd 100644 --- a/packages/db-server/src/routes/messages.ts +++ b/packages/db-server/src/routes/messages.ts @@ -7,21 +7,30 @@ */ import { SQL } from "sql-template-strings"; +import { log } from "../lib/log.js"; import type { QueryFn } from "../lib/sqlite.js"; +/** 频道上与 QQ 互通相关的列。游戏发言出站和 QQ 入站都读这份结构。 */ +export interface ChatChannelQQFlags { + id?: string; + prefix: string; + forward_to_qq: number; + source_game?: number; + source_qq?: number; + source_system?: number; + /** 私聊不提供转发开关,提交后的出站要跳过。 */ + type?: string; +} + interface Deps { query: QueryFn; body: (req: import("http").IncomingMessage) => Promise>; json: (res: import("http").ServerResponse, data: Record, status?: number) => void; forwardToQQBridge: (channelId: string, prefix: string, fromName: string, content: string, fromId: string) => void; /** 查询来源频道的前缀和 QQ 转发开关。 */ - getChannelForQQ: (channelId: string) => { - prefix: string; - forward_to_qq: number; - source_game?: number; - source_qq?: number; - source_system?: number; - } | null; + getChannelForQQ: (channelId: string) => ChatChannelQQFlags | null; + /** 消息来源为 QQ 的频道。入站不看请求里的频道 id。 */ + listQQSourceChannels: () => ChatChannelQQFlags[]; } /** 是否应将本条消息推到 QQ 群 */ @@ -38,7 +47,44 @@ export function shouldForwardChatToQQ( return true; } -function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannelForQQ }: Deps) { +/** + * 只保留消息来源为 QQ 的频道。 + * 使用场景:QQ 入站扇出,不使用桥接请求里写死的频道 id。 + */ +export function channelsAcceptingQQ(channels: T[]): T[] { + return channels.filter((channel) => channel.source_qq === 1); +} + +/** + * 游戏侧 db.tx 插入的聊天行是否要转发到 QQ。 + * 字段名是蛇形(from_id / channel_id),与 /api/sfmc/messages 的驼峰载荷不同。 + */ +export function gameChatForwardPayload( + row: Record, + channel: ChatChannelQQFlags | null, +): { + channelId: string; + prefix: string; + fromName: string; + content: string; + fromId: string; +} | null { + const channelId = String(row.channel_id ?? row.channelId ?? "").trim(); + const fromId = String(row.from_id ?? row.fromid ?? ""); + // 广播 / SYS 不是玩家发言。私聊没有转发开关,避免旧数据把私聊推到群里。 + if (fromId === "system" || fromId === "SYSTEM" || channel?.type === "private") return null; + if (!channelId || !shouldForwardChatToQQ(channel, fromId)) return null; + const prefix = String(channel?.prefix ?? "").trim() || channelId; + return { + channelId, + prefix, + fromName: String(row.from_name ?? row.fromName ?? ""), + content: String(row.content ?? ""), + fromId, + }; +} + +function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannelForQQ, listQQSourceChannels }: Deps) { return async function handle({ path, method, @@ -81,33 +127,56 @@ function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannel json(res, { success: false, error: "too many requests" }, 413); return true; } + const incoming = messages as Array>; + // QQ 入站不看请求频道,只写入消息来源为 QQ 的频道。 + const qqDestinations = channelsAcceptingQQ(listQQSourceChannels()).filter( + (channel) => String(channel.id ?? "").trim() !== "", + ); // 先检查整个批次,避免前几条已写入后才发现被禁用的来源。 - for (const m of messages as Array>) { - const channel = getChannelForQQ(String(m.channelId ?? "")); + for (const m of incoming) { const fromId = String(m.fromid ?? ""); - const sourceField = fromId.startsWith("qq_") - ? "source_qq" - : fromId === "system" || fromId === "SYSTEM" - ? "source_system" - : "source_game"; + if (fromId.startsWith("qq_")) continue; + const channel = getChannelForQQ(String(m.channelId ?? "")); + const sourceField = + fromId === "system" || fromId === "SYSTEM" ? "source_system" : "source_game"; if (channel?.[sourceField] === 0) { json(res, { success: false, error: "channel source disabled" }, 403); return true; } } - // 多行 INSERT 改成循环单条 INSERT OR REPLACE —— 简单优先 - for (const m of messages as Array>) { + const insertMessage = ( + id: unknown, + channelId: unknown, + m: Record, + ) => { query( SQL`INSERT OR REPLACE INTO sfmc_chat_messages ( id, channel_id, from_id, from_name, type, content, attachment, show_timestamp, created_at ) VALUES ( - ${m.id}, ${m.channelId}, ${m.fromid}, ${m.fromName}, + ${id}, ${channelId}, ${m.fromid}, ${m.fromName}, ${m.type ?? "text"}, ${m.content}, ${m.attachment ?? null}, ${m.showTimestamp ? 1 : 0}, ${m.timestamp} )` ); + }; + let sawQQInbound = false; + for (const m of incoming) { + const fromId = String(m.fromid ?? ""); + if (fromId.startsWith("qq_")) { + sawQQInbound = true; + // 每个目标频道一行,主键带上频道 id,避免多频道互相覆盖。 + for (const channel of qqDestinations) { + const channelId = String(channel.id); + insertMessage(`${String(m.id ?? "")}__${channelId}`, channelId, m); + } + continue; + } + insertMessage(m.id, m.channelId, m); + } + if (sawQQInbound && qqDestinations.length === 0) { + log.warn("没有消息来源为 QQ 的频道,QQ 入站未写入"); } - for (const m of messages as Array>) { + for (const m of incoming) { const channelId = String(m.channelId ?? ""); const fromId = String(m.fromid ?? ""); if (!fromId.trim() || fromId.startsWith("qq_")) continue; @@ -121,7 +190,7 @@ function createMessagesRoutes({ query, body, json, forwardToQQBridge, getChannel fromId ); } - json(res, { success: true }); + json(res, { success: true, qqChannels: sawQQInbound ? qqDestinations.length : undefined }); } else { json(res, { success: false, error: "not_found" }, 404); } diff --git a/packages/db-server/src/tx-runner.ts b/packages/db-server/src/tx-runner.ts index 20b18d15..fb51d187 100644 --- a/packages/db-server/src/tx-runner.ts +++ b/packages/db-server/src/tx-runner.ts @@ -131,8 +131,16 @@ export interface TxRequest { steps: TxStep[]; } +/** 事务提交成功后的插入行。聊天消息据此决定是否转发到 QQ。 */ +export interface CommittedInsert { + table: string; + row: Record; +} + interface TxSession { moduleId: string; + /** 已成功执行的步骤,提交后用于 afterCommit,不进入响应体。 */ + steps: TxStep[]; results: TxStepResult[]; idleTimer: ReturnType; } @@ -163,6 +171,11 @@ export class TxRunner { private readonly transactionWaiters: TransactionWaiter[] = []; private readonly slotWaitTimeoutMs: number; private readonly sessionIdleTimeoutMs: number; + /** + * 提交成功后的插入通知。游戏聊天经 db.tx 落库,不走 /api/sfmc/messages, + * 因此 MC→QQ 要在这里挂钩。钩子不得抛错影响已提交结果。 + */ + private afterCommit?: (inserts: CommittedInsert[]) => void; constructor( private readonly deps: TxRunnerDeps, @@ -172,6 +185,14 @@ export class TxRunner { this.sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? 15_000; } + /** + * 注册提交成功后的插入回调。 + * 使用场景:db-server 启动后把聊天行转发给 QQ,避免事务层依赖具体桥接实现。 + */ + setAfterCommit(hook: (inserts: CommittedInsert[]) => void): void { + this.afterCommit = hook; + } + async run(req: TxRequest): Promise { const { moduleId, steps } = req; const manifest = this.deps.enabled.get(moduleId); @@ -217,6 +238,7 @@ export class TxRunner { } this.releaseTransactionSlot(); + this.emitAfterCommit(steps); log.info(`[tx ${traceId}] module=${moduleId} ${steps.length} steps OK`); return { ok: true, results }; } @@ -247,6 +269,7 @@ export class TxRunner { const txId = randomUUID(); this.sessions.set(txId, { moduleId, + steps: [], results: [], idleTimer: this.createSessionIdleTimer(txId, moduleId), }); @@ -283,6 +306,7 @@ export class TxRunner { const stepIndex = session.results.length; try { const r = await this.runOne(moduleId, manifest, step); + session.steps.push(step); session.results.push(r); session.idleTimer = this.createSessionIdleTimer(txId, moduleId); return { ok: true, result: r }; @@ -317,12 +341,30 @@ export class TxRunner { return { ok: false, step: -1, error: `COMMIT 失败: ${(e as Error).message}`, code: "internal" }; } const results = session.results; + const steps = session.steps; this.sessions.delete(txId); this.releaseTransactionSlot(); + this.emitAfterCommit(steps); log.info(`[tx-session ${txId.slice(0, 8)}] commit ${results.length} steps OK`); return { ok: true, results }; } + /** 只通知插入行。回调失败只记日志,事务已经提交。 */ + private emitAfterCommit(steps: TxStep[]): void { + const hook = this.afterCommit; + if (!hook) return; + const inserts: CommittedInsert[] = []; + for (const step of steps) { + if (step.op === "insert") inserts.push({ table: step.table, row: step.row }); + } + if (inserts.length === 0) return; + try { + hook(inserts); + } catch (err) { + log.warn(`[tx] afterCommit: ${(err as Error).message}`); + } + } + /** * 回滚当前交互式事务会话,撤销所有未提交的数据库变更。 * 具有幂等性:会话已结束或已回滚时同样返回成功。 diff --git a/packages/qq-bridge/CHANGELOG.md b/packages/qq-bridge/CHANGELOG.md index 06dfad8e..89d557d5 100644 --- a/packages/qq-bridge/CHANGELOG.md +++ b/packages/qq-bridge/CHANGELOG.md @@ -109,7 +109,7 @@ - efa6e73: 修正官方群指令面板同步:全局面板不再请求关联群,指定群面板仅在缺少目标群时追加关联。 - efa6e73: QQ 查服改用游戏实时在线玩家及世界信息,CLI 托管的 BDS 启停和异常退出现在上报事件。 - efa6e73: 官方 QQ 机器人接收群全量消息时,可直接识别无需 @ 的指令,并避免与群 @ 事件重复处理。 -- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和世界包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 +- 3465c7e: 更新 QQ 账号和服务器信息面板、频道来源控制及模块和资源包查询;移除 QQ 踢人指令,完善 CLI 服务窗口提示与模块配置行为。 - efa6e73: 为 QQ 管理菜单增加可即时生效的服务器事件推送开关,并补充 BDS 正常停服通知。 - Updated dependencies [4a9b066] - Updated dependencies [aadd2de] diff --git a/packages/qq-bridge/src/commands/content-format.ts b/packages/qq-bridge/src/commands/content-format.ts index 36affd56..c59b774f 100644 --- a/packages/qq-bridge/src/commands/content-format.ts +++ b/packages/qq-bridge/src/commands/content-format.ts @@ -1,8 +1,8 @@ /** - * content-format.ts — 把模块 / 世界包快照排成 QQ 信息卡正文。 + * content-format.ts — 把模块 / 资源包快照排成 QQ 信息卡正文。 * * 「模块」列出启停和是否自带资源包。 - * 「世界包」的 Markdown 用表格分列行为包和资源包;纯文本仍保留原来的逐行列表,供 Markdown 发送失败时降级。 + * 「资源包」的 Markdown 用表格分列行为包和资源包;纯文本仍保留原来的逐行列表,供 Markdown 发送失败时降级。 */ export type ContentModuleView = { @@ -12,7 +12,7 @@ export type ContentModuleView = { has_resource_pack?: boolean; }; -/** 一条世界包。kind 缺省时按资源包处理,兼容还没分开返回的旧快照。 */ +/** 一条资源包。kind 缺省时按资源包处理,兼容还没分开返回的旧快照。 */ export type WorldPackView = { kind?: "behavior" | "resource"; name?: string; @@ -27,7 +27,12 @@ export function moduleMatches(mod: ContentModuleView, keyword: string): boolean const q = keyword.trim().toLowerCase(); if (!q) return true; const name = String(mod.display_name || mod.id || "").toLowerCase(); - return name.includes(q) || String(mod.id || "").toLowerCase().includes(q); + return ( + name.includes(q) || + String(mod.id || "") + .toLowerCase() + .includes(q) + ); } /** 模块查询正文。已启用排在前面,便于在群里扫一眼当前在跑什么。 */ @@ -54,14 +59,12 @@ export function formatModuleLines(modules: ContentModuleView[], keyword = ""): s return lines; } -/** 单条世界包:真实名称优先,语言键残留时退回文件夹名。 */ +/** 单条资源包:真实名称优先,语言键残留时退回文件夹名。 */ function formatWorldPackLine(pack: WorldPackView): string { const resolved = String(pack.name || "").trim(); const folder = String(pack.folder_name || "").trim(); const name = - !resolved || resolved.startsWith("pack.") || resolved === "pack" - ? folder || pack.pack_id || "未命名" - : resolved; + !resolved || resolved.startsWith("pack.") || resolved === "pack" ? folder || pack.pack_id || "未命名" : resolved; const folderBit = folder && folder !== name ? `${folder} · ` : ""; const version = pack.version ? ` v${pack.version}` : ""; const state = pack.enabled === false ? "关" : "开"; @@ -70,7 +73,11 @@ function formatWorldPackLine(pack: WorldPackView): string { /** 表格单元格:去掉基岩版颜色码,并转义会拆列的竖线。 */ function markdownCell(value: string): string { - const text = value.replace(/§[0-9a-zA-Z]/g, "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim(); + const text = value + .replace(/§[0-9a-zA-Z]/g, "") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " ") + .trim(); return text || "—"; } @@ -79,9 +86,7 @@ function packFields(pack: WorldPackView): { state: string; name: string; version const resolved = String(pack.name || "").trim(); const folder = String(pack.folder_name || "").trim(); const name = - !resolved || resolved.startsWith("pack.") || resolved === "pack" - ? folder || pack.pack_id || "未命名" - : resolved; + !resolved || resolved.startsWith("pack.") || resolved === "pack" ? folder || pack.pack_id || "未命名" : resolved; return { state: pack.enabled === false ? "关" : "开", name: markdownCell(name), @@ -98,21 +103,21 @@ function markdownTable(rows: Array<{ state: string; name: string; version: strin } /** - * 世界包的 Markdown 正文。行为包、资源包各一张表,已启用排在前面。 + * 资源包的 Markdown 正文。行为包、资源包各一张表,已启用排在前面。 * 读不到世界时只给说明,不输出空表。 */ export function formatPackMarkdown(packs: WorldPackView[], worldNote?: string): string { if (worldNote === "bds_unconfigured") { - return ["## 世界包", "", "未配置 BDS 路径,暂时无法读取世界行为包和资源包。"].join("\n"); + return ["## 资源包", "", "未配置 BDS 路径,暂时无法读取世界行为包和资源包。"].join("\n"); } if (worldNote === "world_unread") { - return ["## 世界包", "", "世界行为包和资源包暂时读不出来。"].join("\n"); + return ["## 资源包", "", "世界行为包和资源包暂时读不出来。"].join("\n"); } const sections: Array<{ kind: "behavior" | "resource"; title: string; empty: string }> = [ { kind: "behavior", title: "行为包", empty: "世界里没有行为包。" }, { kind: "resource", title: "资源包", empty: "世界里没有资源包。" }, ]; - const lines = ["## 世界包", ""]; + const lines = ["## 资源包", ""]; for (const section of sections) { const group = packs.filter((pack) => (pack.kind ?? "resource") === section.kind); const enabled = group.filter((pack) => pack.enabled !== false); @@ -129,7 +134,7 @@ export function formatPackMarkdown(packs: WorldPackView[], worldNote?: string): } /** - * 世界包查询正文。行为包和资源包分开展示,已启用排在前面。 + * 资源包查询正文。行为包和资源包分开展示,已启用排在前面。 * worldNote 来自 db-server:未配置 BDS 或世界目录读失败时只说明原因。 */ export function formatPackLines(packs: WorldPackView[], worldNote?: string): string[] { @@ -155,3 +160,4 @@ export function formatPackLines(packs: WorldPackView[], worldNote?: string): str } return lines; } + diff --git a/packages/qq-bridge/src/commands/db-api.ts b/packages/qq-bridge/src/commands/db-api.ts index 58ab0bcb..052a5b1c 100644 --- a/packages/qq-bridge/src/commands/db-api.ts +++ b/packages/qq-bridge/src/commands/db-api.ts @@ -279,7 +279,7 @@ export type ContentSnapshotResponse = { }>; }; -/** 查询游戏机上的模块与世界行为包、资源包。当前给 QQ「模块」「世界包」使用。 */ +/** 查询游戏机上的模块与世界行为包、资源包。当前给 QQ「模块」「资源包」使用。 */ export async function fetchContent(ep: DbEndpoint): Promise { const { data } = await requestJson(ep, "GET", "/api/sfmc/content"); return data; diff --git a/packages/qq-bridge/src/commands/handlers.ts b/packages/qq-bridge/src/commands/handlers.ts index c8e20a20..6fd1b19d 100644 --- a/packages/qq-bridge/src/commands/handlers.ts +++ b/packages/qq-bridge/src/commands/handlers.ts @@ -4,6 +4,8 @@ import { getGroupBotState, getGroupInfo } from "@sfmc-bds/sdk/node/qq-official"; import { log } from "../log.js"; +import { formatAccountCard } from "./account-format.js"; +import { formatModuleLines, formatPackLines, formatPackMarkdown } from "./content-format.js"; import { fetchBindMe, fetchContent, @@ -16,8 +18,6 @@ import { type QqEventSettings, type SfmcStatusResponse, } from "./db-api.js"; -import { formatAccountCard } from "./account-format.js"; -import { formatModuleLines, formatPackLines, formatPackMarkdown } from "./content-format.js"; import { formatCard, formatCommandMenu } from "./menu-format.js"; import type { CommandRegistry } from "./registry.js"; import type { CommandContext, CommandHandler, CommandResult } from "./types.js"; @@ -466,7 +466,7 @@ export const eventSettingsHandler: CommandHandler = async (ctx): Promise ({ - text: "QQ群消息进入游戏内只读的 QQ 频道。游戏内各频道可分别设置是否转发到 QQ。", + text: "QQ群消息进入游戏内已开启「QQ」消息来源的频道。游戏内各频道可分别设置是否转发到 QQ。", }); export const doctorHandler: CommandHandler = async (ctx) => { @@ -479,7 +479,7 @@ export const doctorHandler: CommandHandler = async (ctx) => { text: [ "管理自检", `后端:${ctx.inbound.backend}`, - `QQ 入站频道:${ctx.runtimeInfo.bridgeChannelId || "qq"}`, + "QQ 入站:写入消息来源为 QQ 的游戏频道", `主机:${st.host?.hostname || "未知"} · ${st.host?.platform || "未知"}`, `CPU:${st.host?.cpu?.model || "未知"} · ${st.host?.cpu?.cores ?? "未知"} 核`, `内存:${st.host?.memory?.usedMb ?? "未知"}/${st.host?.memory?.totalMb ?? "未知"} MB`, @@ -522,7 +522,7 @@ export const packsHandler: CommandHandler = async (ctx) => { ...(pack.version ? { version: pack.version } : {}), })); return { - text: ["世界包", "", ...formatPackLines(packs, data.note)].join("\n"), + text: ["资源包", "", ...formatPackLines(packs, data.note)].join("\n"), markdown: formatPackMarkdown(packs, data.note), }; } catch (error) { @@ -558,13 +558,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { "home" ); add("account", ["我的账号"], "查看与管理游戏角色绑定", whoamiHandler, "home"); - add( - "entry", - ["入服"], - "直接进服,游戏内绑定后即可游玩", - entryGuideHandler, - "home" - ); + add("entry", ["入服"], "直接进服,游戏内绑定后即可游玩", entryGuideHandler, "home"); add("help", ["帮助"], "查看使用说明和常用命令", helpHandler(registry), "home"); add("admin", ["管理"], "管理菜单", createAdminMenuHandler(registry), "home", true); add("status", ["查服", "状态"], "服务器运行状态", statusHandler, "server"); @@ -572,7 +566,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { add("version", ["版本"], "基岩版入服版本", serverVersionHandler, "server"); add("ip", ["地址"], "公开服务器地址与端口", serverAddressHandler, "server"); add("modules", ["模块"], "查看已安装模块", modulesHandler, "server"); - add("packs", ["资源包", "行为包", "世界包"], "查看世界行为包和资源包", packsHandler, "server"); + add("packs", ["资源包", "行为包", "资源包"], "查看世界行为包和资源包", packsHandler, "server"); add("channel", ["频道"], "聊天互通状态", channelHandler, "server"); add("ping", [], "机器人连通性", pingHandler, "server"); add("whoami", ["我的绑定"], "查看绑定角色", whoamiHandler, "account"); diff --git a/packages/qq-bridge/src/commands/menu-format.ts b/packages/qq-bridge/src/commands/menu-format.ts index a167910a..6b2e0ffa 100644 --- a/packages/qq-bridge/src/commands/menu-format.ts +++ b/packages/qq-bridge/src/commands/menu-format.ts @@ -13,7 +13,7 @@ export function pickDisplayLabel(cmd: RegisteredCommand, maxChars = 8): string { channel: "聊天互通", ping: "连接检查", modules: "模块列表", - packs: "世界包", + packs: "资源包", }; const label = labels[cmd.name]; if (label) return label.slice(0, maxChars); diff --git a/packages/qq-bridge/src/commands/message-text.ts b/packages/qq-bridge/src/commands/message-text.ts index e7203503..36cc8615 100644 --- a/packages/qq-bridge/src/commands/message-text.ts +++ b/packages/qq-bridge/src/commands/message-text.ts @@ -6,9 +6,7 @@ export function officialPlainFallback(result: CommandResult): string { const items = result.buttons .filter((button) => button.command !== "/menu") .map((button) => `${button.label}${button.description ? ` · ${button.description}` : ""}`); - const home = result.buttons.some((button) => button.command === "/menu") - ? ["", "发送「菜单」返回首页。"] - : []; + const home = result.buttons.some((button) => button.command === "/menu") ? ["", "发送「菜单」返回首页。"] : []; return [result.text, "", ...items, ...home].join("\n"); } @@ -31,7 +29,7 @@ function byteLength(lines: string[]): number { /** * 按行拆分 Markdown,并保证每一段里的表格仍然完整。 * 一张表超长时,下一段重复表头,并在小节标题后标明「续」。 - * 当前给 QQ 世界包表格使用:官方消息按段发送,半张表无法渲染。 + * 当前给 QQ 资源包表格使用:官方消息按段发送,半张表无法渲染。 */ export function splitMarkdownMessage(markdown: string, maxBytes = 3000): string[] { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); diff --git a/packages/qq-bridge/src/commands/reply-official.ts b/packages/qq-bridge/src/commands/reply-official.ts index 003f0349..b4d3156b 100644 --- a/packages/qq-bridge/src/commands/reply-official.ts +++ b/packages/qq-bridge/src/commands/reply-official.ts @@ -22,7 +22,7 @@ export function createOfficialReplyPort(creds: QqOfficialCredentials): ReplyPort return { async send(target: ReplyTarget, result: CommandResult, inbound: InboundMessage): Promise { const markdown = result.markdown?.trim() ?? ""; - // 有 Markdown 时按表格边界拆段,避免超长世界包列表被降成纯文本。 + // 有 Markdown 时按表格边界拆段,避免超长资源包列表被降成纯文本。 const parts = markdown ? splitMarkdownMessage(markdown) : splitMessage(result.text); for (let index = 0; index < parts.length; index++) { const content = parts[index]!; diff --git a/packages/qq-bridge/src/console.ts b/packages/qq-bridge/src/console.ts index 4a92a00d..05549a75 100644 --- a/packages/qq-bridge/src/console.ts +++ b/packages/qq-bridge/src/console.ts @@ -65,7 +65,7 @@ function handleCommand(line: string, opts: ConsoleOptions, rl: RLInterface): boo case "reload": { reloadInto(opts.config); log.info( - `配置已重载 (backend=${opts.config.qq_backend}, channel=qq, db=${opts.config.db_host}:${opts.config.db_port})` + `配置已重载 (backend=${opts.config.qq_backend}, db=${opts.config.db_host}:${opts.config.db_port})` ); log.warn("reload 只更新内存配置引用;切换 backend / AppID 需重启进程"); return false; diff --git a/packages/qq-bridge/src/dbserver.ts b/packages/qq-bridge/src/dbserver.ts index 38fd10ed..1c3781c4 100644 --- a/packages/qq-bridge/src/dbserver.ts +++ b/packages/qq-bridge/src/dbserver.ts @@ -13,7 +13,11 @@ import type { IncomingChatMessage, PostMessagesBody } from "./types.js"; export interface DBServerConfig { host: string; port: number; - channelId: string; + /** + * 已不再作为入站目标。QQ 消息由 db-server 写入消息来源为 QQ 的频道。 + * 保留字段只为兼容旧调用方,转发时不会带上。 + */ + channelId?: string; } const REQUEST_TIMEOUT_MS = 5_000; @@ -76,10 +80,9 @@ export async function forwardGroupMessage( fromName: string, content: string, now: number = Date.now() -): Promise { +): Promise<{ qqChannels?: number }> { const message: IncomingChatMessage = { id: `${fromId}_${now}`, - channelId: cfg.channelId, fromid: fromId, fromName, type: "text", @@ -89,7 +92,7 @@ export async function forwardGroupMessage( }; const body: PostMessagesBody = { messages: [message] }; const url = new URL(`http://${cfg.host}:${cfg.port}/api/sfmc/messages`); - await postJSON(url, body, REQUEST_TIMEOUT_MS); + return postJSON<{ qqChannels?: number }>(url, body, REQUEST_TIMEOUT_MS); } /** @@ -107,7 +110,11 @@ export async function tryForward( content: string ): Promise { try { - await forwardGroupMessage(cfg, fromId, fromName, content); + const result = await forwardGroupMessage(cfg, fromId, fromName, content); + if (result && typeof result === "object" && result.qqChannels === 0) { + log.warn("没有消息来源为 QQ 的游戏频道,本条未投递"); + return; + } log.info(`QQ → MC: ${fromName}: ${content.slice(0, 60)}`); } catch (e) { log.error(`转发到 db-server 失败: ${(e as Error).message}`); diff --git a/packages/qq-bridge/src/index.ts b/packages/qq-bridge/src/index.ts index da182d75..57e689cc 100644 --- a/packages/qq-bridge/src/index.ts +++ b/packages/qq-bridge/src/index.ts @@ -39,7 +39,6 @@ async function main(): Promise { const db = { host: cfg.db_host, port: cfg.db_port, - channelId: "qq", }; if (cfg.qq_backend === "official") { @@ -64,7 +63,6 @@ async function main(): Promise { dbPort: cfg.db_port, groupOpenid: cfg.qq_group_openid, adminOpenids: Array.isArray(cfg.qq_admin_openids) ? cfg.qq_admin_openids : [], - bridgeChannelId: "qq", officialCreds: creds, }, startedAt @@ -122,7 +120,7 @@ async function main(): Promise { installQqRuntimeStatusHooks("official"); log.info( - `官方后端已启动 (transport=${cfg.qq_official_transport}, sandbox=${cfg.qq_sandbox}, group_openid=${cfg.qq_group_openid || "未配置"}, channel=qq, db=${cfg.db_host}:${cfg.db_port})` + `官方后端已启动 (transport=${cfg.qq_official_transport}, sandbox=${cfg.qq_sandbox}, group_openid=${cfg.qq_group_openid || "未配置"}, db=${cfg.db_host}:${cfg.db_port})` ); startConsole({ @@ -146,7 +144,6 @@ async function main(): Promise { dbHost: cfg.db_host, dbPort: cfg.db_port, adminOpenids: Array.isArray(cfg.qq_admin_openids) ? cfg.qq_admin_openids : [], - bridgeChannelId: "qq", }, startedAt ); @@ -166,7 +163,7 @@ async function main(): Promise { installQqRuntimeStatusHooks("llbot"); log.info( - `LLBot 后端等待连接 (主群: ${cfg.qq_group_id || "未配置"}, channel: qq, db: ${cfg.db_host}:${cfg.db_port})` + `LLBot 后端等待连接 (主群: ${cfg.qq_group_id || "未配置"}, db: ${cfg.db_host}:${cfg.db_port})` ); startConsole({ diff --git a/packages/qq-bridge/src/official/events.ts b/packages/qq-bridge/src/official/events.ts index b1089382..a29c0988 100644 --- a/packages/qq-bridge/src/official/events.ts +++ b/packages/qq-bridge/src/official/events.ts @@ -134,7 +134,7 @@ export class OfficialAtMessageDispatcher { this.forward = opts.forward ?? tryForward; } - /** 全量群消息只处理指令;普通聊天仍需 @机器人 才转发到游戏。 */ + /** 全量群消息处理指令和普通聊天;与 @ 事件共用消息 ID 去重。 */ async handleGroupMessage(msg: OfficialGroupAtMessage): Promise { if (!msg || typeof msg !== "object" || msg.author?.bot) return; const groupOpenid = String(msg.group_openid ?? ""); @@ -144,28 +144,29 @@ export class OfficialAtMessageDispatcher { msg.mentions?.length && !msg.mentions.some((mention) => mention.is_you) ) return; - const router = this.opts.commandRouter; - if (!router) return; const text = extractOfficialText(msg); if (!text) return; + if (this.dedup.seen(msg.id)) return; + const router = this.opts.commandRouter; const normalized = text.replace(/^[//]+/, "").trim(); const isConfirmation = /^(confirm|确认|cancel|取消)(?:\s+\S+)?$/i.test(normalized); - if (!router.registry.resolve(normalized) && !isConfirmation && !/^[//]/.test(text)) return; - // 全量与 @ 事件可能同时到达;先占用消息 ID,再执行异步指令。 - if (this.dedup.seen(msg.id)) return; - const memberId = String(msg.author?.member_openid || msg.author?.id || "unknown"); const fromName = String(msg.author?.username || `QQ_${memberId.slice(0, 8)}`); - await router.handle({ - backend: "official", - scene: "group", - groupId: groupOpenid, - userId: memberId, - userName: fromName, - text, - isGroupAdmin: detectOfficialGroupAdmin(msg), - ...(msg.id ? { msgId: String(msg.id) } : {}), - }); + if ( + router && + (router.registry.resolve(normalized) || isConfirmation || /^[//]/.test(text)) && + await router.handle({ + backend: "official", + scene: "group", + groupId: groupOpenid, + userId: memberId, + userName: fromName, + text, + isGroupAdmin: detectOfficialGroupAdmin(msg), + ...(msg.id ? { msgId: String(msg.id) } : {}), + }) + ) return; + await this.forward(this.opts.db, `qq_${memberId}`, fromName, text); } async handleGroupAtMessage(msg: OfficialGroupAtMessage): Promise { diff --git a/packages/qq-bridge/src/onebot.ts b/packages/qq-bridge/src/onebot.ts index b4184d9e..8cda078a 100644 --- a/packages/qq-bridge/src/onebot.ts +++ b/packages/qq-bridge/src/onebot.ts @@ -135,7 +135,7 @@ export class RecentMessageDedup { export interface DispatcherOptions { /** 群 ID 字符串,空 = 接收所有群(旧行为:空时不匹配任何群,不会触发) */ qqGroupId: string; - /** db-server 配置 + 目标 channelId */ + /** db-server 连接。入站频道由消息来源为 QQ 的游戏频道决定。 */ db: DBServerConfig; /** QQ 侧指令路由;命中则不转发 MC */ commandRouter?: CommandRouter; diff --git a/packages/qq-bridge/src/types.ts b/packages/qq-bridge/src/types.ts index e3420aa4..cff7a790 100644 --- a/packages/qq-bridge/src/types.ts +++ b/packages/qq-bridge/src/types.ts @@ -133,7 +133,8 @@ export interface QQBridgeConfig { // ── POST 到 db-server 的消息载荷 ────────────────────────────── export interface IncomingChatMessage { id: string; - channelId: string; + /** 游戏侧写入时使用。QQ 入站不带此字段,由 db-server 按消息来源投递。 */ + channelId?: string; fromid: string; fromName: string; type: "text"; diff --git a/packages/tools/CHANGELOG.md b/packages/tools/CHANGELOG.md index c7142f99..fcbba821 100644 --- a/packages/tools/CHANGELOG.md +++ b/packages/tools/CHANGELOG.md @@ -279,7 +279,7 @@ ### Patch Changes -- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。世界包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 +- 3714053: 发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。资源包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 - Updated dependencies [3714053] - Updated dependencies [5ada90e] - @sfmc-bds/bds-tools@0.2.0-beta.2