@sunjuntao/dsh-qqbot 0.1.7 → 0.1.10

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.
Files changed (3) hide show
  1. package/lib/client.js +195 -31
  2. package/lib/index.js +320 -13
  3. package/package.json +4 -4
package/lib/client.js CHANGED
@@ -608,6 +608,7 @@ var cn = Object.freeze({
608
608
  "update.noNewVersion": "暂无新版本(当前 v{0} 已是最新)",
609
609
  "update.found": "发现新版本 v{0},正在自动更新…",
610
610
  "update.updatedTo": "已自动更新到 v{0}(备份于安装目录 .update-backup/),重启 DSH 后生效",
611
+ "update.keepOpen": "更新尚未执行完,请勿关闭本面板",
611
612
  // ── replyLocale ──
612
613
  "replyLocale.label": "回复语言(replyLocale)",
613
614
  "replyLocale.labelShort": "回复语言",
@@ -754,7 +755,7 @@ var cn = Object.freeze({
754
755
  "panel.archiveAllHint": "所有机器人模式仅显示聚合条数;选择单个机器人可展开预览记录",
755
756
  "panel.tabId": "面板",
756
757
  "ui.panelSwitchTitle": "右侧面板「QQ 定时消息」入口",
757
- "ui.panelSwitchDesc": "开启后,编辑器右侧栏显示 QQ 定时消息管理入口;关闭即隐藏(仍要求至少一个机器人已连接,且改动即时生效)",
758
+ "ui.panelSwitchDesc": "默认关闭。开启后,编辑器右侧栏显示 QQ 定时消息管理入口并记住该选择:刷新页面、重启 DSH、QQ 掉线都不会让它消失;关闭即隐藏(改动即时生效)",
758
759
  "sched.view": "查看",
759
760
  "sched.back": "返回",
760
761
  "sched.detailTitle": "执行逻辑详情",
@@ -1344,6 +1345,7 @@ var en = Object.freeze({
1344
1345
  "update.noNewVersion": "No new version (v{0} is the latest)",
1345
1346
  "update.found": "New version v{0} found; updating automatically…",
1346
1347
  "update.updatedTo": "Updated to v{0} (old files backed up in .update-backup/ inside the install directory). Restart DSH to take effect.",
1348
+ "update.keepOpen": "Update still running — do not close this panel",
1347
1349
  // ── replyLocale ──
1348
1350
  "replyLocale.label": "Reply language (replyLocale)",
1349
1351
  "replyLocale.labelShort": "Reply language",
@@ -1490,7 +1492,7 @@ var en = Object.freeze({
1490
1492
  "panel.archiveAllHint": "All-bots mode shows aggregated counts only; pick a single bot to expand and preview records",
1491
1493
  "panel.tabId": "Tab",
1492
1494
  "ui.panelSwitchTitle": 'Right panel "QQ Scheduled Messages" entry',
1493
- "ui.panelSwitchDesc": "When on, the QQ scheduled-messages manager appears in the right sidebar; off hides it (still requires at least one connected bot, and takes effect immediately)",
1495
+ "ui.panelSwitchDesc": "Off by default. When on, the QQ scheduled-messages manager appears in the right sidebar and the choice is remembered: reloading, restarting DSH, or losing the QQ connection will not hide it; off hides it (takes effect immediately)",
1494
1496
  "sched.view": "View",
1495
1497
  "sched.back": "Back",
1496
1498
  "sched.detailTitle": "Execution details",
@@ -1745,6 +1747,53 @@ function QqBotGuideIcon(props) {
1745
1747
  return h(QqBotGlyph, { uid: props.uid ?? "guide", className: props.className, size: props.size });
1746
1748
  }
1747
1749
 
1750
+ // src/client/settings-nav-icon.ts
1751
+ var QBOT_SETTINGS_NAV_MARKER = "data-qbot-settings-nav";
1752
+ var NAV_ICON_MASK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.5v2.5'/%3E%3Ccircle cx='12' cy='2.5' r='.9' fill='black' stroke='none'/%3E%3Crect x='5' y='6' width='14' height='12' rx='3.5'/%3E%3Ccircle cx='9.4' cy='11.5' r='.9' fill='black' stroke='none'/%3E%3Ccircle cx='14.6' cy='11.5' r='.9' fill='black' stroke='none'/%3E%3Cpath d='M9.5 14.8q2.5 1.8 5 0'/%3E%3C/svg%3E";
1753
+ var QBOT_SETTINGS_NAV_CSS = `
1754
+ [${QBOT_SETTINGS_NAV_MARKER}] > svg:first-child {
1755
+ display: none;
1756
+ }
1757
+ [${QBOT_SETTINGS_NAV_MARKER}]::before {
1758
+ content: '';
1759
+ flex: none;
1760
+ width: 16px;
1761
+ height: 16px;
1762
+ background: currentColor;
1763
+ -webkit-mask: url("${NAV_ICON_MASK}") center / contain no-repeat;
1764
+ mask: url("${NAV_ICON_MASK}") center / contain no-repeat;
1765
+ }
1766
+ `;
1767
+ function registerQbotSettingsNavIcon(label) {
1768
+ let disposed = false;
1769
+ const sync = () => {
1770
+ if (disposed) return;
1771
+ const currentLabel = label().trim();
1772
+ const buttons = document.querySelectorAll(
1773
+ '[role="dialog"] nav button'
1774
+ );
1775
+ for (const button of Array.from(buttons)) {
1776
+ const matches2 = currentLabel.length > 0 && button.textContent?.trim() === currentLabel;
1777
+ if (matches2) button.setAttribute(QBOT_SETTINGS_NAV_MARKER, "");
1778
+ else button.removeAttribute(QBOT_SETTINGS_NAV_MARKER);
1779
+ }
1780
+ };
1781
+ sync();
1782
+ const observer = new MutationObserver(sync);
1783
+ observer.observe(document.body, {
1784
+ childList: true,
1785
+ subtree: true,
1786
+ characterData: true
1787
+ });
1788
+ return () => {
1789
+ disposed = true;
1790
+ observer.disconnect();
1791
+ document.querySelectorAll(`[${QBOT_SETTINGS_NAV_MARKER}]`).forEach((element) => {
1792
+ element.removeAttribute(QBOT_SETTINGS_NAV_MARKER);
1793
+ });
1794
+ };
1795
+ }
1796
+
1748
1797
  // src/client/ui.tsx
1749
1798
  var React2 = __toESM(require("react"), 1);
1750
1799
 
@@ -1999,6 +2048,10 @@ var CSS_TEXT = `
1999
2048
  .qbot-noticeCount { margin-left: auto; flex: none; align-self: flex-start; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 12px; line-height: 1.4; padding: 1px 9px; border-radius: 999px; white-space: nowrap; background: color-mix(in srgb, var(--qbot-business) 14%, var(--dsw-alias-bg-layer-1, #fff)); border: 1px solid color-mix(in srgb, var(--qbot-business) 28%, var(--dsw-alias-border-l2, #dfe1e5)); }
2000
2049
  /* 页面级更新提示(qbot-page 是普通块布局,无 channelPage 的 flex gap,需自带下边距与面板隔开) */
2001
2050
  .qbot-updateNotice { margin: 0 0 18px; }
2051
+ /* 进行中:不吃 8s 淡出动画(更新可能耗时较久),并改为竖排以突出「请勿关闭」警示行。 */
2052
+ .qbot-updateNotice.is-busy { flex-direction: column; align-items: stretch; gap: 6px; animation: none; }
2053
+ .qbot-updateMsg { min-width: 0; overflow-wrap: anywhere; }
2054
+ .qbot-updateKeepOpen { color: var(--dsw-alias-state-error-primary, #d54941); font-size: 13px; font-weight: 650; line-height: 1.5; }
2002
2055
 
2003
2056
  /* ── 机器人卡片(dim-botCard)─────────────────────────────────────────── */
2004
2057
  .qbot-botList { min-width: 0; width: 100%; max-width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
@@ -5193,9 +5246,9 @@ function notifyBotsChanged() {
5193
5246
  var PANEL_VISIBLE_KEY = "dsh-qqbot.panel.visible";
5194
5247
  function getPanelVisible() {
5195
5248
  try {
5196
- return localStorage.getItem(PANEL_VISIBLE_KEY) !== "0";
5249
+ return localStorage.getItem(PANEL_VISIBLE_KEY) === "1";
5197
5250
  } catch {
5198
- return true;
5251
+ return false;
5199
5252
  }
5200
5253
  }
5201
5254
  function setPanelVisible(v) {
@@ -5320,9 +5373,41 @@ function TitleView() {
5320
5373
  var name = "qqbot-settings";
5321
5374
  var inject = ["slots", "connection", "locale", "sidebarRightTabs", "sidebarRight"];
5322
5375
  var RPC_CHANNEL = "/qqbot-settings";
5376
+ function eventsWsUrl() {
5377
+ const scheme = typeof location !== "undefined" && location.protocol === "https:" ? "wss:" : "ws:";
5378
+ return `${scheme}//${location.host}${RPC_CHANNEL}/events`;
5379
+ }
5323
5380
  var QQBOT_PANEL_ID = "@sunjuntao/dsh-qqbot";
5324
5381
  var QQBOT_PANEL_KIND = "qqbot-sched";
5325
5382
  var NOTICE_TTL_SECONDS = 8;
5383
+ var UPDATE_IDLE = { busy: false, message: "", done: false };
5384
+ var updateStore = /* @__PURE__ */ (() => {
5385
+ let current = UPDATE_IDLE;
5386
+ const listeners2 = /* @__PURE__ */ new Set();
5387
+ return {
5388
+ snapshot() {
5389
+ return current;
5390
+ },
5391
+ publish(next) {
5392
+ current = next.busy ? next : UPDATE_IDLE;
5393
+ for (const listener of [...listeners2]) {
5394
+ try {
5395
+ listener(next);
5396
+ } catch {
5397
+ }
5398
+ }
5399
+ },
5400
+ subscribe(listener) {
5401
+ listeners2.add(listener);
5402
+ return () => {
5403
+ listeners2.delete(listener);
5404
+ };
5405
+ }
5406
+ };
5407
+ })();
5408
+ function publishUpdate(next) {
5409
+ updateStore.publish(next);
5410
+ }
5326
5411
  function QqbotSettingsTab({ rpcCall }) {
5327
5412
  const [status, setStatus] = React10.useState(null);
5328
5413
  const [bots, setBots] = React10.useState(null);
@@ -5339,11 +5424,9 @@ function QqbotSettingsTab({ rpcCall }) {
5339
5424
  const [refreshing, setRefreshing] = React10.useState(false);
5340
5425
  const [reconnecting, setReconnecting] = React10.useState(false);
5341
5426
  const [panelVisible, setPanelVisibleState] = React10.useState(() => getPanelVisible());
5342
- const [update, setUpdate] = React10.useState({
5343
- busy: false,
5344
- message: "",
5345
- done: false
5346
- });
5427
+ const [update, setLocalUpdate] = React10.useState(() => updateStore.snapshot());
5428
+ const setUpdate = publishUpdate;
5429
+ React10.useEffect(() => updateStore.subscribe(setLocalUpdate), []);
5347
5430
  const refresh = React10.useCallback(async (appId) => {
5348
5431
  try {
5349
5432
  const [s, c, b, cat] = await Promise.all([
@@ -5488,9 +5571,9 @@ function QqbotSettingsTab({ rpcCall }) {
5488
5571
  };
5489
5572
  React10.useEffect(() => {
5490
5573
  if (!update.message || update.busy) return void 0;
5491
- const timer = setTimeout(() => setUpdate((u) => ({ ...u, message: "" })), 8e3);
5574
+ const timer = setTimeout(() => publishUpdate({ ...update, message: "" }), NOTICE_TTL_SECONDS * 1e3);
5492
5575
  return () => clearTimeout(timer);
5493
- }, [update.message, update.busy]);
5576
+ }, [update]);
5494
5577
  const groupOverrides = form.groupOverrides && typeof form.groupOverrides === "object" && !Array.isArray(form.groupOverrides) ? form.groupOverrides : {};
5495
5578
  const overrideSummary = (ov) => {
5496
5579
  const parts = [];
@@ -6179,7 +6262,7 @@ function QqbotSettingsTab({ rpcCall }) {
6179
6262
  "div",
6180
6263
  { className: "qbot-brandHeading" },
6181
6264
  h("strong", { className: "qbot-brandName" }, t("app.title")),
6182
- h("span", { className: "qbot-brandVersion" }, `v${true ? "0.1.7" : "0.0.2"}`),
6265
+ h("span", { className: "qbot-brandVersion" }, `v${true ? "0.1.10" : "0.0.2"}`),
6183
6266
  h("button", {
6184
6267
  className: `qbot-btn qbot-updateBtn${update.done ? " is-done" : ""}`,
6185
6268
  type: "button",
@@ -6193,7 +6276,19 @@ function QqbotSettingsTab({ rpcCall }) {
6193
6276
  ),
6194
6277
  h("div", { className: "qbot-titleActions" }, globalBadge)
6195
6278
  ),
6196
- update.message ? h("div", { className: "qbot-infoNotice qbot-updateNotice", role: "status" }, update.message) : null,
6279
+ // 更新期间(busy)的提示条不自动淡出,并额外给出「请勿关闭」警示:
6280
+ // update.apply 会下载并覆盖安装目录,中途切走 / 关闭面板不影响宿主执行,
6281
+ // 但用户看不到结果、也容易误以为卡死,所以显式告知。
6282
+ update.message ? h(
6283
+ "div",
6284
+ {
6285
+ className: `qbot-infoNotice qbot-updateNotice${update.busy ? " is-busy" : ""}`,
6286
+ role: "status",
6287
+ "aria-busy": update.busy ? "true" : "false"
6288
+ },
6289
+ h("span", { className: "qbot-updateMsg" }, update.message),
6290
+ update.busy ? h("strong", { className: "qbot-updateKeepOpen" }, t("update.keepOpen")) : null
6291
+ ) : null,
6197
6292
  h(
6198
6293
  "div",
6199
6294
  { className: "qbot-panel", id: "qbot-panel" },
@@ -6270,17 +6365,31 @@ function apply(ctx) {
6270
6365
  };
6271
6366
  setRpcCall(rpcCall);
6272
6367
  ctx.effect(() => installStyles(), "qqbot-settings: styles");
6368
+ ctx.effect(
6369
+ () => {
6370
+ let style = document.getElementById("qbot-settings-nav-style");
6371
+ if (!style) {
6372
+ style = document.createElement("style");
6373
+ style.id = "qbot-settings-nav-style";
6374
+ style.textContent = QBOT_SETTINGS_NAV_CSS;
6375
+ document.head.appendChild(style);
6376
+ }
6377
+ const disposeMarker = registerQbotSettingsNavIcon(() => t("app.title"));
6378
+ return () => {
6379
+ disposeMarker();
6380
+ style?.remove();
6381
+ };
6382
+ },
6383
+ "qqbot-settings: settings navigation icon"
6384
+ );
6273
6385
  ctx.slots.inject("settings.section", () => ctx.slots.register(
6274
6386
  {
6275
6387
  name: "settings.section",
6276
6388
  id: "dsh-qqbot",
6277
6389
  order: 31,
6278
- label: () => h(
6279
- "span",
6280
- { className: "qbot-navLabel" },
6281
- h(QqBotGlyph, { className: "qbot-navGlyph", uid: "nav" }),
6282
- h("span", { className: "qbot-navText" }, t("app.title"))
6283
- ),
6390
+ // 宿主新版导航行自带系统图标(navIcon),label 契约是纯文本;
6391
+ // 这里不再内嵌 QqBotGlyph,否则会和宿主图标重复。
6392
+ label: () => t("app.title"),
6284
6393
  inject: () => ({ rpcCall })
6285
6394
  },
6286
6395
  QqbotSettingsTab
@@ -6311,22 +6420,77 @@ function apply(ctx) {
6311
6420
  }
6312
6421
  disposePanelTab = null;
6313
6422
  };
6314
- const syncPanelTab = async () => {
6315
- try {
6316
- const res = await rpcCall("bots.list");
6317
- const bots = res.ok ? val(res)?.bots : void 0;
6318
- const anyConnected = Array.isArray(bots) && bots.some((b) => b?.ws?.state === "connected");
6319
- if (anyConnected && getPanelVisible()) registerPanelTab();
6320
- else unregisterPanelTab();
6321
- } catch {
6322
- }
6423
+ const syncPanelTab = () => {
6424
+ if (getPanelVisible()) registerPanelTab();
6425
+ else unregisterPanelTab();
6323
6426
  };
6324
- void syncPanelTab();
6427
+ syncPanelTab();
6325
6428
  const offBotsChanged = onBotsChanged(() => void syncPanelTab());
6326
- const panelTabTimer = setInterval(() => void syncPanelTab(), 15e3);
6429
+ const onStorage = (event) => {
6430
+ if (event.storageArea === localStorage && (event.key === null || event.key === PANEL_VISIBLE_KEY)) {
6431
+ syncPanelTab();
6432
+ }
6433
+ };
6434
+ window.addEventListener("storage", onStorage);
6435
+ let wsSocket = null;
6436
+ let wsRetry = 0;
6437
+ let wsTimer = null;
6438
+ let wsDisposed = false;
6439
+ const scheduleWsReconnect = () => {
6440
+ if (wsDisposed || wsTimer !== null) return;
6441
+ const delay = Math.min(1e3 * 2 ** wsRetry, 1e4);
6442
+ wsRetry = Math.min(wsRetry + 1, 10);
6443
+ wsTimer = setTimeout(() => {
6444
+ wsTimer = null;
6445
+ connectWs();
6446
+ }, delay);
6447
+ };
6448
+ function connectWs() {
6449
+ if (wsDisposed || typeof WebSocket === "undefined") return;
6450
+ let socket;
6451
+ try {
6452
+ socket = new WebSocket(eventsWsUrl());
6453
+ } catch (error) {
6454
+ console?.debug?.("[dsh-qqbot] WebSocket 订阅失败,面板显隐依赖设置页动作触发:", error);
6455
+ scheduleWsReconnect();
6456
+ return;
6457
+ }
6458
+ wsSocket = socket;
6459
+ socket.onopen = () => {
6460
+ wsRetry = 0;
6461
+ syncPanelTab();
6462
+ };
6463
+ socket.onmessage = (event) => {
6464
+ if (typeof event.data !== "string") return;
6465
+ try {
6466
+ const message = JSON.parse(event.data);
6467
+ if (message.event === "bots-changed") notifyBotsChanged();
6468
+ } catch {
6469
+ }
6470
+ };
6471
+ socket.onerror = () => {
6472
+ };
6473
+ socket.onclose = () => {
6474
+ if (wsSocket === socket) wsSocket = null;
6475
+ scheduleWsReconnect();
6476
+ };
6477
+ }
6478
+ connectWs();
6327
6479
  ctx.effect(
6328
6480
  () => () => {
6329
- clearInterval(panelTabTimer);
6481
+ wsDisposed = true;
6482
+ if (wsTimer !== null) clearTimeout(wsTimer);
6483
+ wsTimer = null;
6484
+ try {
6485
+ const socket = wsSocket;
6486
+ wsSocket = null;
6487
+ if (socket) {
6488
+ socket.onclose = null;
6489
+ socket.close();
6490
+ }
6491
+ } catch {
6492
+ }
6493
+ window.removeEventListener("storage", onStorage);
6330
6494
  offBotsChanged();
6331
6495
  unregisterPanelTab();
6332
6496
  },
package/lib/index.js CHANGED
@@ -6722,7 +6722,7 @@ async function readRawBody(req) {
6722
6722
  }
6723
6723
  return Buffer.concat(chunks, size);
6724
6724
  }
6725
- function registerRpcChannel({ ctx, fence, dispatch }) {
6725
+ function registerRpcChannel({ ctx, fence, dispatch, onWsUpgrade }) {
6726
6726
  ctx.inject(["webServer"], (webCtxUnknown) => {
6727
6727
  const webCtx = webCtxUnknown;
6728
6728
  const route = {
@@ -6741,7 +6741,8 @@ function registerRpcChannel({ ctx, fence, dispatch }) {
6741
6741
  }
6742
6742
  }
6743
6743
  const endpoint = endpointFromPath(RPC_CHANNEL, new URL(req.url ?? "/", "http://localhost").pathname);
6744
- if ((req.method ?? "GET").toUpperCase() !== "POST" || endpoint === void 0) {
6744
+ const method = (req.method ?? "GET").toUpperCase();
6745
+ if (method !== "POST" || endpoint === void 0) {
6745
6746
  res.writeHead(404);
6746
6747
  res.end("not found");
6747
6748
  return;
@@ -6780,8 +6781,46 @@ function registerRpcChannel({ ctx, fence, dispatch }) {
6780
6781
  }
6781
6782
  };
6782
6783
  webCtx.effect(() => webCtx.webServer.register(route), `dsh-qqbot: ${RPC_CHANNEL} rpc channel`);
6784
+ if (!onWsUpgrade) return;
6785
+ const upgradeRoute = {
6786
+ path: `${RPC_CHANNEL}/events`,
6787
+ handler: (req, socket, head) => {
6788
+ if (fence) {
6789
+ try {
6790
+ const rejection = fence.requestRejection({ headers: req.headers });
6791
+ if (rejection !== void 0) {
6792
+ rejectUpgrade(socket, rejection);
6793
+ return;
6794
+ }
6795
+ } catch {
6796
+ }
6797
+ }
6798
+ onWsUpgrade(req, socket, head);
6799
+ }
6800
+ };
6801
+ webCtx.effect(() => {
6802
+ try {
6803
+ return webCtx.webServer.registerUpgrade(upgradeRoute);
6804
+ } catch (error) {
6805
+ console?.warn?.(`[dsh-qqbot] WebSocket 端点注册失败,面板显隐仅依赖设置页动作: ${String(error)}`);
6806
+ return () => {
6807
+ };
6808
+ }
6809
+ }, `dsh-qqbot: ${RPC_CHANNEL} events websocket`);
6783
6810
  });
6784
6811
  }
6812
+ function rejectUpgrade(socket, status) {
6813
+ const text = status === 401 ? "Unauthorized" : "Forbidden";
6814
+ try {
6815
+ socket.write(`HTTP/1.1 ${status} ${text}\r
6816
+ Connection: close\r
6817
+ Content-Length: 0\r
6818
+ \r
6819
+ `);
6820
+ } catch {
6821
+ }
6822
+ socket.destroy();
6823
+ }
6785
6824
  async function writeJson2(res, response) {
6786
6825
  const buffer = Buffer.from(await response.arrayBuffer());
6787
6826
  const headers = {};
@@ -6792,6 +6831,235 @@ async function writeJson2(res, response) {
6792
6831
  res.end(buffer);
6793
6832
  }
6794
6833
 
6834
+ // src/host/admin/ws-hub.ts
6835
+ import { createHash as createHash2 } from "node:crypto";
6836
+ var OPCODE_CONTINUATION = 0;
6837
+ var OPCODE_TEXT = 1;
6838
+ var OPCODE_CLOSE = 8;
6839
+ var OPCODE_PING = 9;
6840
+ var OPCODE_PONG = 10;
6841
+ var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
6842
+ var HEARTBEAT_INTERVAL_MS = 25e3;
6843
+ var MAX_PAYLOAD_BYTES = 1024 * 1024;
6844
+ var WS_EVENT_BOTS_CHANGED = "bots-changed";
6845
+ function acceptKey(key) {
6846
+ return createHash2("sha1").update(`${key}${WS_GUID}`).digest("base64");
6847
+ }
6848
+ function encodeFrame(opcode, payload) {
6849
+ const length = payload.length;
6850
+ let header;
6851
+ if (length < 126) {
6852
+ header = Buffer.alloc(2);
6853
+ header[1] = length;
6854
+ } else if (length <= 65535) {
6855
+ header = Buffer.alloc(4);
6856
+ header[1] = 126;
6857
+ header.writeUInt16BE(length, 2);
6858
+ } else {
6859
+ header = Buffer.alloc(10);
6860
+ header[1] = 127;
6861
+ header.writeBigUInt64BE(BigInt(length), 2);
6862
+ }
6863
+ header[0] = 128 | opcode;
6864
+ return Buffer.concat([header, payload]);
6865
+ }
6866
+ function parseFrame(buf) {
6867
+ if (buf.length < 2) return { kind: "incomplete", rest: buf };
6868
+ const first = buf[0];
6869
+ const second = buf[1];
6870
+ const fin = (first & 128) !== 0;
6871
+ const opcode = first & 15;
6872
+ const masked = (second & 128) !== 0;
6873
+ let length = second & 127;
6874
+ let offset = 2;
6875
+ if (length === 126) {
6876
+ if (buf.length < offset + 2) return { kind: "incomplete", rest: buf };
6877
+ length = buf.readUInt16BE(offset);
6878
+ offset += 2;
6879
+ } else if (length === 127) {
6880
+ if (buf.length < offset + 8) return { kind: "incomplete", rest: buf };
6881
+ const declared = buf.readBigUInt64BE(offset);
6882
+ offset += 8;
6883
+ if (declared > BigInt(MAX_PAYLOAD_BYTES)) return { kind: "fatal" };
6884
+ length = Number(declared);
6885
+ }
6886
+ if (length > MAX_PAYLOAD_BYTES) return { kind: "fatal" };
6887
+ let mask;
6888
+ if (masked) {
6889
+ if (buf.length < offset + 4) return { kind: "incomplete", rest: buf };
6890
+ mask = buf.subarray(offset, offset + 4);
6891
+ offset += 4;
6892
+ }
6893
+ if (buf.length < offset + length) return { kind: "incomplete", rest: buf };
6894
+ const payload = Buffer.from(buf.subarray(offset, offset + length));
6895
+ if (mask) {
6896
+ for (let i = 0; i < payload.length; i += 1) payload[i] = payload[i] ^ mask[i & 3];
6897
+ }
6898
+ return { kind: "frame", frame: { fin, opcode, payload }, rest: buf.subarray(offset + length) };
6899
+ }
6900
+ function isReservedOpcode(opcode) {
6901
+ return opcode >= 3 && opcode <= 7 || opcode >= 11;
6902
+ }
6903
+ function createWsHub({ logger }) {
6904
+ const clients = /* @__PURE__ */ new Set();
6905
+ const drop = (client) => {
6906
+ clients.delete(client);
6907
+ };
6908
+ const send = (client, opcode, payload) => {
6909
+ if (client.socket.destroyed) {
6910
+ drop(client);
6911
+ return;
6912
+ }
6913
+ try {
6914
+ client.socket.write(encodeFrame(opcode, payload), (error) => {
6915
+ if (error) drop(client);
6916
+ });
6917
+ } catch (error) {
6918
+ drop(client);
6919
+ logger.warn("[dsh-qqbot] WebSocket 写入失败,已摘除该客户端:", error);
6920
+ }
6921
+ };
6922
+ const close = (client, code = 1001) => {
6923
+ if (!client.socket.destroyed) {
6924
+ const body = Buffer.alloc(2);
6925
+ body.writeUInt16BE(code, 0);
6926
+ send(client, OPCODE_CLOSE, body);
6927
+ }
6928
+ drop(client);
6929
+ try {
6930
+ client.socket.end();
6931
+ } catch {
6932
+ }
6933
+ };
6934
+ const attach = (client, onText, head) => {
6935
+ let buffer = Buffer.alloc(0);
6936
+ const fragments = [];
6937
+ let fragmentOpcode = OPCODE_CONTINUATION;
6938
+ const feed = (chunk) => {
6939
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
6940
+ if (buffer.length > MAX_PAYLOAD_BYTES + 16) {
6941
+ client.socket.destroy();
6942
+ return;
6943
+ }
6944
+ for (; ; ) {
6945
+ const result = parseFrame(buffer);
6946
+ if (result.kind === "incomplete") {
6947
+ buffer = result.rest;
6948
+ return;
6949
+ }
6950
+ if (result.kind === "fatal") {
6951
+ client.socket.destroy();
6952
+ return;
6953
+ }
6954
+ buffer = result.rest;
6955
+ const { fin, opcode, payload } = result.frame;
6956
+ if (opcode === OPCODE_CONTINUATION) {
6957
+ fragments.push(payload);
6958
+ if (!fin) continue;
6959
+ const full = Buffer.concat(fragments);
6960
+ fragments.length = 0;
6961
+ if (fragmentOpcode === OPCODE_TEXT) onText(full.toString("utf8"));
6962
+ fragmentOpcode = OPCODE_CONTINUATION;
6963
+ continue;
6964
+ }
6965
+ if (isReservedOpcode(opcode)) {
6966
+ client.socket.destroy();
6967
+ return;
6968
+ }
6969
+ if (opcode === OPCODE_TEXT) {
6970
+ if (!fin) {
6971
+ fragmentOpcode = OPCODE_TEXT;
6972
+ fragments.length = 0;
6973
+ fragments.push(payload);
6974
+ continue;
6975
+ }
6976
+ onText(payload.toString("utf8"));
6977
+ continue;
6978
+ }
6979
+ if (opcode === OPCODE_PING) {
6980
+ send(client, OPCODE_PONG, payload);
6981
+ continue;
6982
+ }
6983
+ if (opcode === OPCODE_PONG) {
6984
+ client.alive = true;
6985
+ continue;
6986
+ }
6987
+ if (opcode === OPCODE_CLOSE) {
6988
+ close(client, 1e3);
6989
+ return;
6990
+ }
6991
+ }
6992
+ };
6993
+ client.socket.on("data", (chunk) => {
6994
+ feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"));
6995
+ });
6996
+ client.socket.on("error", () => drop(client));
6997
+ client.socket.on("close", () => drop(client));
6998
+ if (head.length > 0) feed(head);
6999
+ };
7000
+ const timer = setInterval(() => {
7001
+ for (const client of [...clients]) {
7002
+ if (!client.alive) {
7003
+ drop(client);
7004
+ try {
7005
+ client.socket.destroy();
7006
+ } catch {
7007
+ }
7008
+ continue;
7009
+ }
7010
+ client.alive = false;
7011
+ send(client, OPCODE_PING, Buffer.alloc(0));
7012
+ }
7013
+ }, HEARTBEAT_INTERVAL_MS);
7014
+ timer.unref?.();
7015
+ return {
7016
+ handleUpgrade(req, socket, head) {
7017
+ const upgrade = String(req.headers.upgrade ?? "").toLowerCase();
7018
+ const key = req.headers["sec-websocket-key"];
7019
+ const versions = String(req.headers["sec-websocket-version"] ?? "").split(",").map((v) => v.trim());
7020
+ if (upgrade !== "websocket" || typeof key !== "string" || key === "" || !versions.includes("13")) {
7021
+ try {
7022
+ socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
7023
+ } catch {
7024
+ }
7025
+ socket.destroy();
7026
+ return;
7027
+ }
7028
+ try {
7029
+ socket.write(
7030
+ `HTTP/1.1 101 Switching Protocols\r
7031
+ Upgrade: websocket\r
7032
+ Connection: Upgrade\r
7033
+ Sec-WebSocket-Accept: ${acceptKey(key)}\r
7034
+ \r
7035
+ `
7036
+ );
7037
+ } catch (error) {
7038
+ logger.warn("[dsh-qqbot] WebSocket 握手响应失败:", error);
7039
+ socket.destroy();
7040
+ return;
7041
+ }
7042
+ const client = { socket, alive: true };
7043
+ clients.add(client);
7044
+ attach(client, () => {
7045
+ }, head ?? Buffer.alloc(0));
7046
+ },
7047
+ broadcast(event, data) {
7048
+ if (clients.size === 0) return;
7049
+ const payload = Buffer.from(JSON.stringify({ event, data: data ?? null, at: Date.now() }), "utf8");
7050
+ for (const client of [...clients]) send(client, OPCODE_TEXT, payload);
7051
+ },
7052
+ clientCount() {
7053
+ return clients.size;
7054
+ },
7055
+ dispose() {
7056
+ clearInterval(timer);
7057
+ for (const client of [...clients]) close(client);
7058
+ clients.clear();
7059
+ }
7060
+ };
7061
+ }
7062
+
6795
7063
  // src/host/messaging/self-id.ts
6796
7064
  init_store_file();
6797
7065
  import { mkdir as mkdir7, readFile as readFile7, writeFile as writeFile7 } from "node:fs/promises";
@@ -6997,6 +7265,7 @@ var QqWsSource = class {
6997
7265
  #onEvent;
6998
7266
  #onRawEvent;
6999
7267
  #onInteraction;
7268
+ #onStatusChange;
7000
7269
  #bot = null;
7001
7270
  #abort = null;
7002
7271
  #starting = null;
@@ -7006,10 +7275,25 @@ var QqWsSource = class {
7006
7275
  this.#onEvent = options.onEvent;
7007
7276
  this.#onRawEvent = options.onRawEvent;
7008
7277
  this.#onInteraction = options.onInteraction;
7278
+ this.#onStatusChange = options.onStatusChange;
7009
7279
  }
7010
7280
  get status() {
7011
7281
  return { ...this.#status };
7012
7282
  }
7283
+ /**
7284
+ * 统一的状态赋值入口:实际发生 state / lastError 变化时触发 onStatusChange。
7285
+ * events 计数递增走裸赋值(#accept),不经过这里。
7286
+ */
7287
+ #setStatus(status) {
7288
+ const prev = this.#status;
7289
+ this.#status = status;
7290
+ if (prev.state !== status.state || prev.lastError !== status.lastError) {
7291
+ try {
7292
+ this.#onStatusChange?.(status.appId, { ...status });
7293
+ } catch {
7294
+ }
7295
+ }
7296
+ }
7013
7297
  /** 底层 SDK 实例(已连接后可用),供媒体上传 / 撤回 / 原生 API 调用。 */
7014
7298
  get sdk() {
7015
7299
  return this.#bot;
@@ -7018,13 +7302,13 @@ var QqWsSource = class {
7018
7302
  async start(config) {
7019
7303
  await this.stop();
7020
7304
  if (!config.appId || !config.appSecret) {
7021
- this.#status = { ...this.#status, state: "idle", appId: config.appId, lastError: null };
7305
+ this.#setStatus({ ...this.#status, state: "idle", appId: config.appId, lastError: null });
7022
7306
  this.#logger.warn("[dsh-qqbot] WebSocket 未启动:凭据不完整(请扫码登录或填写 AppID/AppSecret)");
7023
7307
  return;
7024
7308
  }
7025
7309
  const run = this.#start(config).catch((error) => {
7026
7310
  const message = error instanceof Error ? error.message : String(error);
7027
- this.#status = { ...this.#status, state: "failed", lastError: message };
7311
+ this.#setStatus({ ...this.#status, state: "failed", lastError: message });
7028
7312
  this.#logger.error("[dsh-qqbot] WebSocket 连接失败:", error);
7029
7313
  });
7030
7314
  this.#starting = run;
@@ -7044,7 +7328,7 @@ var QqWsSource = class {
7044
7328
  bot?.stop();
7045
7329
  } catch {
7046
7330
  }
7047
- this.#status = { ...this.#status, state: "idle" };
7331
+ this.#setStatus({ ...this.#status, state: "idle" });
7048
7332
  }
7049
7333
  async #start(config) {
7050
7334
  const options = {
@@ -7064,18 +7348,18 @@ var QqWsSource = class {
7064
7348
  const abort = new AbortController();
7065
7349
  this.#bot = bot;
7066
7350
  this.#abort = abort;
7067
- this.#status = { state: "connecting", appId: config.appId, lastConnectedAt: null, lastError: null, events: 0 };
7351
+ this.#setStatus({ state: "connecting", appId: config.appId, lastConnectedAt: null, lastError: null, events: 0 });
7068
7352
  bot.on("ready", () => {
7069
- this.#status = { ...this.#status, state: "connected", lastConnectedAt: Date.now(), lastError: null };
7353
+ this.#setStatus({ ...this.#status, state: "connected", lastConnectedAt: Date.now(), lastError: null });
7070
7354
  this.#logger.info(`[dsh-qqbot] QQ WebSocket 已连接(AppID ${config.appId}),开始接收消息`);
7071
7355
  });
7072
7356
  bot.on("resumed", () => {
7073
- this.#status = { ...this.#status, state: "connected", lastError: null };
7357
+ this.#setStatus({ ...this.#status, state: "connected", lastError: null });
7074
7358
  this.#logger.info("[dsh-qqbot] QQ WebSocket 会话已恢复(RESUME)");
7075
7359
  });
7076
7360
  bot.on("error", (error) => {
7077
7361
  const message = error instanceof Error ? error.message : String(error);
7078
- this.#status = { ...this.#status, lastError: message };
7362
+ this.#setStatus({ ...this.#status, lastError: message });
7079
7363
  if (this.#status.state === "connected") {
7080
7364
  this.#logger.warn("[dsh-qqbot] QQ WebSocket 连接错误(SDK 将自动重连):", error);
7081
7365
  }
@@ -7122,7 +7406,7 @@ var QqWsSource = class {
7122
7406
  const runTask = Promise.resolve().then(() => bot.start(abort.signal));
7123
7407
  runTask.catch((error) => {
7124
7408
  if (abort.signal.aborted) return;
7125
- this.#status = { ...this.#status, state: "failed", lastError: error instanceof Error ? error.message : String(error) };
7409
+ this.#setStatus({ ...this.#status, state: "failed", lastError: error instanceof Error ? error.message : String(error) });
7126
7410
  this.#logger.error("[dsh-qqbot] QQ WebSocket 连接停止:", error);
7127
7411
  });
7128
7412
  let timer;
@@ -7218,6 +7502,8 @@ var BotRuntimeManager = class {
7218
7502
  #statsTimer = null;
7219
7503
  #syncing = null;
7220
7504
  #primaryAppId = "";
7505
+ /** onBotsChanged 去抖定时器:窗口内的多次状态变化合并成一次通知。 */
7506
+ #notifyTimer = null;
7221
7507
  constructor(options) {
7222
7508
  this.#options = options;
7223
7509
  this.#stats = new StatsStore(options.logger);
@@ -7277,6 +7563,19 @@ var BotRuntimeManager = class {
7277
7563
  map.delete(key);
7278
7564
  }
7279
7565
  }
7566
+ // ── 变更通知(WebSocket 推送等订阅方) ─────────────────────────────────────
7567
+ /** 去抖通知:300ms 窗口内的多次变化(sync 收尾 + 各 ws 状态迁移)合并为一次。 */
7568
+ #notifyChanged() {
7569
+ if (this.#notifyTimer) return;
7570
+ this.#notifyTimer = setTimeout(() => {
7571
+ this.#notifyTimer = null;
7572
+ try {
7573
+ this.#options.onBotsChanged?.();
7574
+ } catch {
7575
+ }
7576
+ }, 300);
7577
+ this.#notifyTimer.unref?.();
7578
+ }
7280
7579
  // ── 同步:把 bots.json 应用到运行时 ────────────────────────────────────────
7281
7580
  /**
7282
7581
  * 按存储层重建/更新/停止各机器人连接。
@@ -7340,6 +7639,7 @@ var BotRuntimeManager = class {
7340
7639
  this.#bots.delete(appId);
7341
7640
  this.#options.logger.info(`[dsh-qqbot] 机器人 ${appId} 已移除,连接已断开`);
7342
7641
  }
7642
+ this.#notifyChanged();
7343
7643
  }
7344
7644
  #buildConfig(stored, secret) {
7345
7645
  return resolveConfig({
@@ -7402,6 +7702,9 @@ var BotRuntimeManager = class {
7402
7702
  },
7403
7703
  onInteraction: (event) => {
7404
7704
  this.#options.onInteraction?.(bot, event);
7705
+ },
7706
+ onStatusChange: () => {
7707
+ this.#notifyChanged();
7405
7708
  }
7406
7709
  });
7407
7710
  return bot;
@@ -7466,7 +7769,7 @@ init_store_file();
7466
7769
 
7467
7770
  // src/host/schedule/schedule.ts
7468
7771
  init_store_file();
7469
- import { createHash as createHash2, randomUUID as randomUUID3 } from "node:crypto";
7772
+ import { createHash as createHash3, randomUUID as randomUUID3 } from "node:crypto";
7470
7773
 
7471
7774
  // src/shared/cron.ts
7472
7775
  var MONTH_NAMES = {
@@ -8404,7 +8707,7 @@ var ScheduleStore = class {
8404
8707
  }
8405
8708
  };
8406
8709
  function contentDigest(text) {
8407
- return createHash2("sha1").update((text ?? "").replace(/\s+/g, " ").trim()).digest("hex").slice(0, 16);
8710
+ return createHash3("sha1").update((text ?? "").replace(/\s+/g, " ").trim()).digest("hex").slice(0, 16);
8408
8711
  }
8409
8712
  function gateSkipReason(entry, text) {
8410
8713
  const gate = entry.gate ?? "always";
@@ -10299,12 +10602,14 @@ async function apply(ctx, entryConfig) {
10299
10602
  }, 6e4);
10300
10603
  outboxTimer.unref?.();
10301
10604
  let runtime = null;
10605
+ const wsHub = createWsHub({ logger });
10302
10606
  const bots = new BotRuntimeManager({
10303
10607
  logger,
10304
10608
  entryConfig,
10305
10609
  adminToken,
10306
10610
  resolveSecret,
10307
10611
  resolvePresets,
10612
+ onBotsChanged: () => wsHub.broadcast(WS_EVENT_BOTS_CHANGED),
10308
10613
  onEvent: (bot, eventType, payload, deliveryId) => {
10309
10614
  if (!runtime) {
10310
10615
  logger.warn("[dsh-qqbot] webhook 运行时不可用,事件已丢弃");
@@ -10471,7 +10776,8 @@ async function apply(ctx, entryConfig) {
10471
10776
  registerRpcChannel({
10472
10777
  ctx,
10473
10778
  fence: connection,
10474
- dispatch: (endpoint, payload) => admin.handle(endpoint, payload)
10779
+ dispatch: (endpoint, payload) => admin.handle(endpoint, payload),
10780
+ onWsUpgrade: (req, socket, head) => wsHub.handleUpgrade(req, socket, head)
10475
10781
  });
10476
10782
  } catch (error) {
10477
10783
  logger.warn("[dsh-qqbot] RPC 通道注册失败,设置界面将无法连接(HTTP 管理端点仍可用):", error);
@@ -10498,6 +10804,7 @@ async function apply(ctx, entryConfig) {
10498
10804
  disposeTools();
10499
10805
  scheduler.stop();
10500
10806
  qr.dispose();
10807
+ wsHub.dispose();
10501
10808
  await bots.stopAll();
10502
10809
  if (disposeRule) {
10503
10810
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunjuntao/dsh-qqbot",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "description": "把 QQ 官方机器人(Webhook 回调)接入本机 DeepSeek Harness:QQ 群/单聊消息(含群全量消息上下文)→ DSH 会话 → 回复发回 QQ,支持被动回复与群主动消息。Connect the official QQ bot (webhook callback) to a local DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -82,9 +82,9 @@
82
82
  },
83
83
  "peerDependencies": {
84
84
  "@deepseek-ai/cordis": "^4.0.1-rc.1",
85
- "@deepseek-ai/dsh-credentials": "0.0.1-rc.1 || >=0.1.0-rc.1 <0.2.0-0",
86
- "@deepseek-ai/dsh-host-webserver": "0.0.1-rc.1 || >=0.1.0-rc.1 <0.2.0-0",
87
- "@deepseek-ai/dsh-webhook": "0.1.2-alpha.2 || >=0.1.2-rc.1 <0.2.0-0",
85
+ "@deepseek-ai/dsh-credentials": ">=0.1.6-alpha.1 <0.2.0",
86
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.6-alpha.1 <0.2.0",
87
+ "@deepseek-ai/dsh-webhook": ">=0.1.6-alpha.1 <0.2.0",
88
88
  "@deepseek-ai/schemastery": "^3.18.0"
89
89
  },
90
90
  "peerDependenciesMeta": {