dsh-qqbot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # dsh-qqbot
2
+
3
+ QQ 官方机器人插件,作为 DeepSeek Harness 的**树外插件**维护(不在 harness 源码树内)。一个包、两个半:
4
+
5
+ - **host 半**(`lib/index.js`):`/qqbot on|off` 命令、绑定会话、WebSocket 网关、QQ 端审批转发。
6
+ - **client 半**(`lib/client.js`,经 `dsh.client` 声明随同一条挂载行自动加载):输入区工具行的「连接 QQ」开关 + 设置面板的 **QQ Bot 设置页签**。
7
+
8
+ ## 安装
9
+
10
+ 构建,然后装进你运行的 profile:
11
+
12
+ ```sh
13
+ pnpm install
14
+ pnpm run build
15
+ pnpm dsh plugin --profile <name> add -w link:E:/Project/dsh-plugins/dsh-qqbot
16
+ ```
17
+
18
+ 或者手动进入 profile 目录执行等价的 pnpm 命令:
19
+
20
+ ```sh
21
+ cd "$env:DSH_HOME/profiles/<name>" # DSH_HOME 未设置时是 ~/.dsh/profiles/<name>
22
+ pnpm add -w link:E:/Project/dsh-plugins/dsh-qqbot
23
+ ```
24
+
25
+ 注意:
26
+
27
+ - **必须带 `-w`**:profile 目录自带 `pnpm-workspace.yaml`,不带 `-w` 时 pnpm 报 `ERR_PNPM_ADDING_TO_ROOT` 直接失败。
28
+ - **用 `link:` 而不是裸路径**:裸路径是 `file:` 语义(快照副本),改源码不生效;`link:` 直接指向源码目录,重新 `pnpm run build` 后即时生效,无需重新 add。
29
+ - 本包没有 `dsh.bundle` 声明,不会进入 profile 的 bundles 层,这符合预期——由下面的 patch 行按包名挂载;client 半由 `dsh.client` 扫描同一条挂载行自动进浏览器。
30
+
31
+ 在 **profile 自己的** `cordis.patch.yml`(`$DSH_HOME/profiles/<name>/cordis.patch.yml`)挂载(凭据在设置界面填写,不再写在这里):
32
+
33
+ ```yaml
34
+ - insert:
35
+ - id: qqbot
36
+ name: 'dsh-qqbot'
37
+ ```
38
+
39
+ 不要放进 `$DSH_HOME/cordis.patch.yml`(home 层):它作用于**所有** profile,而只有装过这个包的 profile 才能解析——其他 profile(如 headless)会直接启动失败。
40
+
41
+ ## 配置(设置界面)
42
+
43
+ 重启 dsh 后打开 设置(左下角)→ **QQ Bot** 页签,填写:
44
+
45
+ | 字段 | 说明 |
46
+ | --- | --- |
47
+ | AppID | QQ 开放平台机器人应用的 AppID。 |
48
+ | AppSecret | **写穿不回显**:保存后输入框留空表示「保持现有密钥」;页面显示「已配置」,可用「清除密钥」移除。 |
49
+ | 使用沙箱环境 | 勾选后走沙箱网关(`sandbox.api.sgroup.qq.com`),用于开发调试。 |
50
+
51
+ 点击「保存」后**立即生效**:host 半通过 settings 服务热读取(`settings/updated` 提交即触发),已连接的网关会用新身份自动重连,凭据被清空则自动断开。
52
+
53
+ 存储位置:`$DSH_HOME/settings.yaml` 的 `qqbot:` 段(明文,等价于环境变量)。配置解析顺序:**settings 用户层 → cordis.yml 行的 config 基础层 → 环境变量 `QQBOT_APP_ID` / `QQBOT_APP_SECRET`**(无头环境可用环境变量兜底)。三处都没有时插件照常挂载,`/qqbot on` 会报错并指向设置页。
54
+
55
+ ## 测试
56
+
57
+ ```sh
58
+ pnpm test
59
+ ```
60
+
61
+ host 的 `protocol` / `gateway` / `qqbot` / `settings-controller` 四套用例可直接跑(57 例,settings-controller 通过 `vi.mock` 替换 runtime 客户端模块以绕过浏览器 bundle)。`tests/*.client.spec.ts(x)` 被 vitest 排除:它们 import `@deepseek-ai/dsh-client-runtime/client`,而 registry 上发布的是浏览器 bundle(`lib/client.js`,import 时执行 `window.__ModuleLoader__.load(...)`),node/jsdom 无法加载。这些用例在 harness 源码树内开发期已验证,客户端开关与设置页的验证走浏览器手动确认(见「使用」)。
62
+
63
+ ## 使用
64
+
65
+ 1. 设置 → QQ Bot 页签填写 AppID/AppSecret(首次必填)。
66
+ 2. 输入框下方点击「连接 QQ」按钮连接本会话(没试过多连,慎重)。
67
+ 3. QQ 群 @机器人 或私聊发消息,答案回到原聊天;工具调用需要审批时,把「允许/拒绝」发回 QQ 即可。
68
+
69
+ ![](docs/screenshot.png)
70
+
71
+ ## peer 依赖解析
72
+
73
+ `link:` 安装的 symlink 目标在 harness 之外,Node 从真实路径(realpath)向上解析依赖,所以 `@deepseek-ai/cordis`、`dsh-*` Service Definition 这些 peer 命中 **dsh-qqbot 项目自己的 `node_modules`**(`pnpm install` 装的最新 registry 版),不是 profile 的 `node_modules` 或 `$DSH_HOME/profiles/node_modules` 回退。这带来两个后果:
74
+
75
+ - 插件与 harness 各持一份 `@deepseek-ai/cordis`(双实例)。当前启动与挂载正常,但若插件代码对 `ctx` 做跨实例判断(`instanceof` 等)会失效。
76
+ - peer 版本按 `^` 取最新(例如 `dsh-session@0.1.0-rc.6`),可能比正在运行的 harness(rc.5)新。运行时服务走 harness 注入的 ctx,一般兼容;harness 升级或插件用到新 API 时需对齐 peer 版本。
77
+
78
+ ## 版本耦合说明
79
+
80
+ client 半的浏览器 bundle 必须用 harness 的客户端加载协议构建:`tsdown.config.ts` 里硬编码了 `PLATFORM_MODULES`(模块表 external 列表,含 `@deepseek-ai/dsh-client-runtime/client` 的 store 豁免)和 `__ModuleLoader__.load({ id })` 的包名 id。如果 harness 更新了这份平台模块列表或 `__ModuleLoader__` 协议,需要同步更新 `tsdown.config.ts`,否则浏览器端加载会失败。
81
+
82
+ 设置页依赖 harness 的 settings 能力(host 的 `settings` 服务 + `settings.section` 槽位 + `api.settings` wire 面),这些都在 web 基础组合里,无需额外挂载行;如果换成自定义 profile,需保证 `@deepseek-ai/dsh-settings-file` 与 `@deepseek-ai/dsh-client-ui-settings(-general)` 在场。
83
+
84
+ ## 发布
85
+
86
+ 以 npm 身份 `belowthetree` 发布:
87
+
88
+ ```sh
89
+ pnpm run build
90
+ npm login # 首次,登录 belowthetree 账号
91
+ npm publish
92
+ ```
93
+
94
+ 用户即可按名字安装:
95
+
96
+ ```sh
97
+ dsh plugin --profile web add dsh-qqbot -w
98
+ ```
package/lib/client.js ADDED
@@ -0,0 +1,556 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-qqbot",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
8
+ let react = require("react");
9
+ let react_jsx_runtime = require("react/jsx-runtime");
10
+ //#region src/client/settings-controller.ts
11
+ /** The settings namespace the Host plugin registers its identity under. */
12
+ const QQBOT_SETTINGS_NAMESPACE = "qqbot";
13
+ /** Human text for a rejected wire call or transport failure. */
14
+ function messageOf(error) {
15
+ return error instanceof Error ? error.message : String(error);
16
+ }
17
+ /** Read the redacted resolved AppID, defaulting absent values to ''. */
18
+ function appIdIn(view) {
19
+ if (typeof view.value !== "object" || view.value === null) return "";
20
+ const value = view.value["appId"];
21
+ return typeof value === "string" ? value : "";
22
+ }
23
+ /** Read the redacted resolved sandbox flag, defaulting to false. */
24
+ function sandboxIn(view) {
25
+ if (typeof view.value !== "object" || view.value === null) return false;
26
+ return view.value["sandbox"] === true;
27
+ }
28
+ /** Whether the redacted view reports a stored AppSecret. */
29
+ function secretSetIn(view) {
30
+ return view.secrets.some((secret) => secret.path.length === 1 && secret.path[0] === "appSecret" && secret.set);
31
+ }
32
+ /**
33
+ * Serializes the qqbot namespace's Host reads and writes behind one snapshot
34
+ * store. Latest load wins; a refused save reloads the namespace so the form
35
+ * re-renders from the fresh view and the user can re-apply the edit.
36
+ */
37
+ var QqbotSettingsController = class {
38
+ api;
39
+ /** uSES-safe state source the section renders through its hooks compartment. */
40
+ store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)({
41
+ status: "idle",
42
+ appId: "",
43
+ sandbox: false,
44
+ secretSet: false,
45
+ writable: false,
46
+ revision: void 0,
47
+ error: null
48
+ });
49
+ generation = 0;
50
+ /**
51
+ * @param api - the settings wire face.
52
+ */
53
+ constructor(api) {
54
+ this.api = api;
55
+ }
56
+ /**
57
+ * Refresh the snapshot from `settings.describe`; a failure keeps the last
58
+ * good state and surfaces the error.
59
+ * @returns settlement after the read.
60
+ */
61
+ async load() {
62
+ const generation = ++this.generation;
63
+ this.store.update((state) => {
64
+ state.status = "loading";
65
+ state.error = null;
66
+ });
67
+ try {
68
+ const response = await this.api.settings.describe({});
69
+ if (!response.result.ok) throw new Error(response.result.error.message);
70
+ const view = response.result.value.namespaces.find((candidate) => candidate.ns === QQBOT_SETTINGS_NAMESPACE);
71
+ if (view === void 0) throw new Error(`settings namespace "${QQBOT_SETTINGS_NAMESPACE}" is not registered`);
72
+ if (generation !== this.generation) return;
73
+ this.accept(view, response.result.value.writable);
74
+ } catch (error) {
75
+ if (generation !== this.generation) return;
76
+ this.store.update((state) => {
77
+ state.status = "error";
78
+ state.error = messageOf(error);
79
+ });
80
+ }
81
+ }
82
+ /**
83
+ * Apply one user edit as ordered path ops; a refusal reloads the namespace
84
+ * and returns its message.
85
+ * @param patch - the fields the user changed.
86
+ * @returns null on success; the Host's failure text otherwise.
87
+ */
88
+ async save(patch) {
89
+ const ops = [];
90
+ if (patch.appId !== void 0) ops.push(patch.appId.length === 0 ? {
91
+ op: "unset",
92
+ path: ["appId"]
93
+ } : {
94
+ op: "set",
95
+ path: ["appId"],
96
+ value: patch.appId
97
+ });
98
+ if (patch.sandbox !== void 0) ops.push({
99
+ op: "set",
100
+ path: ["sandbox"],
101
+ value: patch.sandbox
102
+ });
103
+ if (patch.clearSecret === true) ops.push({
104
+ op: "unset",
105
+ path: ["appSecret"]
106
+ });
107
+ if (patch.appSecret !== void 0 && patch.appSecret.length > 0) ops.push({
108
+ op: "set",
109
+ path: ["appSecret"],
110
+ value: patch.appSecret
111
+ });
112
+ if (ops.length === 0) return null;
113
+ const revision = this.store.getSnapshot().revision;
114
+ let response;
115
+ try {
116
+ response = await this.api.settings.mutate({
117
+ ns: QQBOT_SETTINGS_NAMESPACE,
118
+ ops,
119
+ ...revision === void 0 ? {} : { expectedRevision: revision }
120
+ });
121
+ } catch (error) {
122
+ await this.load();
123
+ return messageOf(error);
124
+ }
125
+ if (!response.result.ok) {
126
+ await this.load();
127
+ return response.result.error.message;
128
+ }
129
+ this.accept(response.result.value, this.store.getSnapshot().writable);
130
+ return null;
131
+ }
132
+ /** Fold one redacted wire view into the snapshot. */
133
+ accept(view, writable) {
134
+ this.store.update((state) => {
135
+ state.status = "ready";
136
+ state.appId = appIdIn(view);
137
+ state.sandbox = sandboxIn(view);
138
+ state.secretSet = secretSetIn(view);
139
+ state.writable = writable;
140
+ state.revision = view.revision;
141
+ state.error = null;
142
+ });
143
+ }
144
+ };
145
+ /**
146
+ * Refresh only after the section left idle, so an unopened page never fetches
147
+ * on background invalidations.
148
+ * @param controller - the page controller whose current status gates the reload.
149
+ */
150
+ function refreshIfLoaded(controller) {
151
+ if (controller.store.getSnapshot().status === "idle") return;
152
+ controller.load();
153
+ }
154
+ //#endregion
155
+ //#region \0dsh-css:E:\Project\dsh-plugins\dsh-qqbot\src\client\QqbotSettingsSection.module.css.mjs
156
+ const css$1 = ".TV8fAG_section{flex-direction:column;gap:12px;width:100%;padding-bottom:8px;display:flex}.TV8fAG_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:600;line-height:24px}.TV8fAG_desc{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px;line-height:20px}.TV8fAG_field{flex-direction:column;gap:6px;display:flex}.TV8fAG_fieldLabel{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}.TV8fAG_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:100%;max-width:360px;height:32px;font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:14px;line-height:22px}.TV8fAG_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}.TV8fAG_input::placeholder{color:var(--dsw-alias-label-dimmed)}.TV8fAG_input:disabled{opacity:.6;cursor:default}.TV8fAG_fieldHint{color:var(--dsw-alias-label-dimmed);margin:0;font-size:12px;line-height:18px}.TV8fAG_secretRow{align-items:center;gap:8px;width:100%;max-width:360px;display:flex}.TV8fAG_secretRow .TV8fAG_input{flex:auto}.TV8fAG_clearButton{border:1px solid var(--dsw-alias-border-l2);height:28px;font:inherit;color:var(--dsw-alias-state-error-primary);cursor:pointer;background:0 0;border-radius:8px;flex:none;padding:0 10px;font-size:13px;line-height:18px}.TV8fAG_clearButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.TV8fAG_clearButton:disabled{opacity:.6;cursor:default}.TV8fAG_fieldCheck{width:fit-content;color:var(--dsw-alias-label-primary);cursor:pointer;align-items:center;gap:8px;font-size:13px;line-height:20px;display:flex}.TV8fAG_fieldCheck input:disabled{cursor:default}.TV8fAG_actions{align-items:center;gap:12px;display:flex}.TV8fAG_saveButton{height:32px;font:inherit;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary-invert);cursor:pointer;border:none;border-radius:8px;padding:0 16px;font-size:14px;font-weight:500;line-height:20px}.TV8fAG_saveButton:hover:not(:disabled){opacity:.9}.TV8fAG_saveButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.TV8fAG_saveButton:disabled{opacity:.5;cursor:default}.TV8fAG_bound{color:var(--dsw-alias-state-warn-label);font-size:12px;line-height:18px}.TV8fAG_messageOk{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TV8fAG_messageError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}";
157
+ const tagId$1 = "dsh-qqbot/QqbotSettingsSection.module.css";
158
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
159
+ const tag = document.createElement("style");
160
+ tag.dataset.plugin = "dsh-qqbot";
161
+ tag.dataset.pluginCss = tagId$1;
162
+ tag.textContent = css$1;
163
+ document.head.appendChild(tag);
164
+ }
165
+ var QqbotSettingsSection_module_css_default = {
166
+ "actions": "TV8fAG_actions",
167
+ "fieldLabel": "TV8fAG_fieldLabel",
168
+ "bound": "TV8fAG_bound",
169
+ "title": "TV8fAG_title",
170
+ "messageError": "TV8fAG_messageError",
171
+ "input": "TV8fAG_input",
172
+ "fieldCheck": "TV8fAG_fieldCheck",
173
+ "secretRow": "TV8fAG_secretRow",
174
+ "desc": "TV8fAG_desc",
175
+ "field": "TV8fAG_field",
176
+ "fieldHint": "TV8fAG_fieldHint",
177
+ "clearButton": "TV8fAG_clearButton",
178
+ "saveButton": "TV8fAG_saveButton",
179
+ "section": "TV8fAG_section",
180
+ "messageOk": "TV8fAG_messageOk"
181
+ };
182
+ //#endregion
183
+ //#region src/client/QqbotSettingsSection.tsx
184
+ /**
185
+ * The QQ Bot settings page: AppID, AppSecret, and sandbox fields over the
186
+ * Host's redacted namespace view. The AppSecret is write-only on the wire, so
187
+ * the field renders blank and "keep unchanged" is the empty-value meaning;
188
+ * the clear button removes a stored secret. Saving applies every changed
189
+ * field in one ordered write, and the Host restarts a live gateway in place.
190
+ */
191
+ function QqbotSettingsSection({ useQqbotSettings, useQqbotBound, save, t }) {
192
+ const snapshot = useQqbotSettings((state) => state);
193
+ const boundSessionId = useQqbotBound((state) => state.sessionId);
194
+ const [appId, setAppId] = (0, react.useState)("");
195
+ const [secret, setSecret] = (0, react.useState)("");
196
+ const [sandbox, setSandbox] = (0, react.useState)(false);
197
+ const [busy, setBusy] = (0, react.useState)(false);
198
+ const [message, setMessage] = (0, react.useState)(null);
199
+ const revision = snapshot.status === "ready" ? snapshot.revision : void 0;
200
+ (0, react.useEffect)(() => {
201
+ if (snapshot.status !== "ready") return;
202
+ setAppId(snapshot.appId);
203
+ setSecret("");
204
+ setSandbox(snapshot.sandbox);
205
+ }, [revision]);
206
+ const ready = snapshot.status === "ready";
207
+ const dirty = ready && (appId !== snapshot.appId || secret.length > 0 || sandbox !== snapshot.sandbox);
208
+ const finish = (failure) => {
209
+ setBusy(false);
210
+ setMessage(failure === null ? {
211
+ kind: "ok",
212
+ text: t("save.ok")
213
+ } : {
214
+ kind: "error",
215
+ text: failure
216
+ });
217
+ };
218
+ const onClickSave = () => {
219
+ if (busy || !ready || !snapshot.writable || !dirty) return;
220
+ setBusy(true);
221
+ setMessage(null);
222
+ save({
223
+ appId,
224
+ ...secret.length > 0 ? { appSecret: secret } : {},
225
+ sandbox
226
+ }).then(finish, (error) => {
227
+ setBusy(false);
228
+ setMessage({
229
+ kind: "error",
230
+ text: error instanceof Error ? error.message : String(error)
231
+ });
232
+ });
233
+ };
234
+ const onClickClearSecret = () => {
235
+ if (busy || !ready || !snapshot.writable) return;
236
+ setBusy(true);
237
+ setMessage(null);
238
+ save({
239
+ appId,
240
+ sandbox,
241
+ clearSecret: true
242
+ }).then(finish, (error) => {
243
+ setBusy(false);
244
+ setMessage({
245
+ kind: "error",
246
+ text: error instanceof Error ? error.message : String(error)
247
+ });
248
+ });
249
+ };
250
+ let statusLine = null;
251
+ if (snapshot.status === "loading") statusLine = t("status.loading");
252
+ else if (snapshot.status === "error") statusLine = snapshot.error === null ? t("status.error") : `${t("status.error")} ${snapshot.error}`;
253
+ else if (ready && !snapshot.writable) statusLine = t("status.readonly");
254
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
255
+ className: QqbotSettingsSection_module_css_default.section,
256
+ children: [
257
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
258
+ className: QqbotSettingsSection_module_css_default.title,
259
+ children: t("settings.title")
260
+ }),
261
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
262
+ className: QqbotSettingsSection_module_css_default.desc,
263
+ children: t("settings.desc")
264
+ }),
265
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
266
+ className: QqbotSettingsSection_module_css_default.field,
267
+ children: [
268
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
269
+ className: QqbotSettingsSection_module_css_default.fieldLabel,
270
+ children: t("appId.label")
271
+ }),
272
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
273
+ className: QqbotSettingsSection_module_css_default.input,
274
+ value: appId,
275
+ onChange: (event) => {
276
+ setAppId(event.target.value);
277
+ },
278
+ placeholder: t("appId.placeholder"),
279
+ autoComplete: "off",
280
+ spellCheck: false,
281
+ disabled: !ready || !snapshot.writable
282
+ }),
283
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
284
+ className: QqbotSettingsSection_module_css_default.fieldHint,
285
+ children: t("appId.hint")
286
+ })
287
+ ]
288
+ }),
289
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
290
+ className: QqbotSettingsSection_module_css_default.field,
291
+ children: [
292
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
293
+ className: QqbotSettingsSection_module_css_default.fieldLabel,
294
+ children: t("appSecret.label")
295
+ }),
296
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
297
+ className: QqbotSettingsSection_module_css_default.secretRow,
298
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
299
+ className: QqbotSettingsSection_module_css_default.input,
300
+ type: "password",
301
+ value: secret,
302
+ onChange: (event) => {
303
+ setSecret(event.target.value);
304
+ },
305
+ placeholder: snapshot.secretSet ? t("appSecret.placeholder.set") : t("appSecret.placeholder"),
306
+ autoComplete: "new-password",
307
+ disabled: !ready || !snapshot.writable
308
+ }), snapshot.secretSet ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
309
+ type: "button",
310
+ className: QqbotSettingsSection_module_css_default.clearButton,
311
+ onClick: onClickClearSecret,
312
+ disabled: busy,
313
+ children: t("clear.label")
314
+ }) : null]
315
+ }),
316
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
317
+ className: QqbotSettingsSection_module_css_default.fieldHint,
318
+ children: snapshot.secretSet ? t("appSecret.hint.set") : t("appSecret.hint")
319
+ })
320
+ ]
321
+ }),
322
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
323
+ className: QqbotSettingsSection_module_css_default.fieldCheck,
324
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
325
+ type: "checkbox",
326
+ checked: sandbox,
327
+ onChange: (event) => {
328
+ setSandbox(event.target.checked);
329
+ },
330
+ disabled: !ready || !snapshot.writable
331
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sandbox.label") })]
332
+ }),
333
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
334
+ className: QqbotSettingsSection_module_css_default.fieldHint,
335
+ children: t("sandbox.hint")
336
+ }),
337
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
338
+ className: QqbotSettingsSection_module_css_default.actions,
339
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
340
+ type: "button",
341
+ className: QqbotSettingsSection_module_css_default.saveButton,
342
+ onClick: onClickSave,
343
+ disabled: busy || !ready || !snapshot.writable || !dirty,
344
+ children: busy ? t("save.busy") : t("save.label")
345
+ }), boundSessionId !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
346
+ className: QqbotSettingsSection_module_css_default.bound,
347
+ children: [t("bound.prefix"), boundSessionId]
348
+ }) : null]
349
+ }),
350
+ message !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
351
+ className: message.kind === "ok" ? QqbotSettingsSection_module_css_default.messageOk : QqbotSettingsSection_module_css_default.messageError,
352
+ children: message.text
353
+ }) : null,
354
+ statusLine !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
355
+ className: QqbotSettingsSection_module_css_default.messageError,
356
+ children: statusLine
357
+ }) : null
358
+ ]
359
+ });
360
+ }
361
+ //#endregion
362
+ //#region \0dsh-css:E:\Project\dsh-plugins\dsh-qqbot\src\client\QqbotToggle.module.css.mjs
363
+ const css = ".wa26nq_toggle{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);cursor:pointer;border:none;border-radius:999px;align-items:center;gap:4px;padding:2px 8px;font-size:13px;font-weight:500;line-height:20px;display:inline-flex}.wa26nq_toggle:hover:not(:disabled){color:var(--dsw-alias-state-warn-primary)}.wa26nq_toggle:focus-visible{outline:2px solid var(--dsw-alias-state-warn-label);outline-offset:2px}.wa26nq_toggle:disabled{opacity:.6;cursor:default}.wa26nq_connected{background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-tertiary)}";
364
+ const tagId = "dsh-qqbot/QqbotToggle.module.css";
365
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
366
+ const tag = document.createElement("style");
367
+ tag.dataset.plugin = "dsh-qqbot";
368
+ tag.dataset.pluginCss = tagId;
369
+ tag.textContent = css;
370
+ document.head.appendChild(tag);
371
+ }
372
+ var QqbotToggle_module_css_default = {
373
+ "connected": "wa26nq_connected",
374
+ "toggle": "wa26nq_toggle"
375
+ };
376
+ //#endregion
377
+ //#region src/client/QqbotToggle.tsx
378
+ /**
379
+ * Per-session QQ connection toggle over the shared bound-session observable.
380
+ * It reports the host's last admitted bind: `active` is the single bound
381
+ * session, not optimistic click state, so switching sessions shows the truth.
382
+ */
383
+ function QqbotToggle({ sessionId, useQqbotBound, toggle, t }) {
384
+ const active = useQqbotBound((state) => state.sessionId) === sessionId;
385
+ const [busy, setBusy] = (0, react.useState)(false);
386
+ const onClick = () => {
387
+ if (busy) return;
388
+ setBusy(true);
389
+ toggle().then(() => {
390
+ setBusy(false);
391
+ }, () => {
392
+ setBusy(false);
393
+ });
394
+ };
395
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
396
+ type: "button",
397
+ className: active ? `${QqbotToggle_module_css_default.toggle} ${QqbotToggle_module_css_default.connected}` : QqbotToggle_module_css_default.toggle,
398
+ "aria-pressed": active,
399
+ disabled: busy,
400
+ onClick,
401
+ "aria-label": active ? t("toggle.on.aria") : t("toggle.off.aria"),
402
+ title: active ? t("toggle.on.title") : t("toggle.off.title"),
403
+ children: active ? t("toggle.on.label") : t("toggle.off.label")
404
+ });
405
+ }
406
+ //#endregion
407
+ //#region src/client/locales.ts
408
+ /** `qqbot` namespace dictionaries (the composer toggle's and settings page's copy). */
409
+ /** Simplified Chinese dictionary (the key-set source of truth). */
410
+ const zh = {
411
+ "toggle.on.aria": "QQ 机器人已连接本会话,按下断开",
412
+ "toggle.on.title": "QQ 机器人已连接本会话 — 点击断开(/qqbot off)",
413
+ "toggle.on.label": "QQ 已连接",
414
+ "toggle.off.aria": "QQ 机器人未连接,按下连接本会话",
415
+ "toggle.off.title": "QQ 机器人未连接 — 点击连接本会话(/qqbot on)",
416
+ "toggle.off.label": "连接 QQ",
417
+ "settings.nav": "QQ Bot",
418
+ "settings.title": "QQ 官方机器人",
419
+ "settings.desc": "填写 QQ 开放平台机器人应用的 AppID 与 AppSecret 后,在会话中使用 /qqbot on 或输入框的「连接 QQ」开关接入。配置保存后立即生效。",
420
+ "appId.label": "AppID",
421
+ "appId.placeholder": "机器人的 AppID",
422
+ "appId.hint": "QQ 开放平台机器人应用的 AppID。",
423
+ "appSecret.label": "AppSecret",
424
+ "appSecret.placeholder": "输入新的 AppSecret",
425
+ "appSecret.placeholder.set": "已保存(不回显),输入以覆盖",
426
+ "appSecret.hint": "保存后不回显;留空表示保持现有密钥。",
427
+ "appSecret.hint.set": "已配置 AppSecret;留空保存将保持不变,点击「清除密钥」可移除。",
428
+ "clear.label": "清除密钥",
429
+ "sandbox.label": "使用沙箱环境",
430
+ "sandbox.hint": "启用后连接沙箱网关(sandbox.api.sgroup.qq.com),用于开发调试。",
431
+ "save.label": "保存",
432
+ "save.busy": "保存中…",
433
+ "save.ok": "已保存,立即生效。",
434
+ "bound.prefix": "已绑定会话:",
435
+ "status.loading": "正在读取配置…",
436
+ "status.error": "读取配置失败:",
437
+ "status.readonly": "设置存储为只读,无法修改。"
438
+ };
439
+ /** English dictionary, checked complete against the zh key set. */
440
+ const en = {
441
+ "toggle.on.aria": "QQ bot connected to this session, press to disconnect",
442
+ "toggle.on.title": "QQ bot connected to this session — click to disconnect (/qqbot off)",
443
+ "toggle.on.label": "QQ connected",
444
+ "toggle.off.aria": "QQ bot disconnected, press to connect this session",
445
+ "toggle.off.title": "QQ bot disconnected — click to connect this session (/qqbot on)",
446
+ "toggle.off.label": "Connect QQ",
447
+ "settings.nav": "QQ Bot",
448
+ "settings.title": "QQ Official Bot",
449
+ "settings.desc": "Enter the AppID and AppSecret of your QQ open-platform bot application, then use /qqbot on or the \"Connect QQ\" toggle in a session. Saved changes apply immediately.",
450
+ "appId.label": "AppID",
451
+ "appId.placeholder": "Bot AppID",
452
+ "appId.hint": "The AppID of the bot application on the QQ open platform.",
453
+ "appSecret.label": "AppSecret",
454
+ "appSecret.placeholder": "Enter a new AppSecret",
455
+ "appSecret.placeholder.set": "Saved (not shown back), enter to replace",
456
+ "appSecret.hint": "Never shown back after saving; leave empty to keep the stored value.",
457
+ "appSecret.hint.set": "An AppSecret is configured; saving with an empty field keeps it. Use \"Clear secret\" to remove it.",
458
+ "clear.label": "Clear secret",
459
+ "sandbox.label": "Use the sandbox environment",
460
+ "sandbox.hint": "Route token, gateway, and sends through the sandbox host (sandbox.api.sgroup.qq.com) for development.",
461
+ "save.label": "Save",
462
+ "save.busy": "Saving…",
463
+ "save.ok": "Saved, applied immediately.",
464
+ "bound.prefix": "Bound session: ",
465
+ "status.loading": "Loading configuration…",
466
+ "status.error": "Failed to load configuration: ",
467
+ "status.readonly": "Settings storage is read-only."
468
+ };
469
+ //#endregion
470
+ //#region src/client/index.ts
471
+ /**
472
+ * QQ bot plugin, browser half: occupies the composer tool row's always-visible
473
+ * `conversation.input.left` seat with a per-session connection toggle, and
474
+ * registers the QQ Bot page into the settings panel's `settings.section`
475
+ * navigation. The bound-session id and the settings namespace view are shared
476
+ * bare observables held in the inject faces' `hooks` compartments (the
477
+ * renderer binds them to `useQqbotBound`/`useQqbotSettings`); toggling
478
+ * executes /qqbot on | off against the addressed session through
479
+ * `remote.commands`, and settings edits write through the Host's settings
480
+ * wire face.
481
+ */
482
+ /** Dictionary namespace owned by this plugin. */
483
+ const NS = "qqbot";
484
+ /** Required services: the seats' slot registry, connection and commands remotes, and locale. */
485
+ const inject = [
486
+ "slots",
487
+ "remote",
488
+ "remote.commands",
489
+ "connection",
490
+ "locale"
491
+ ];
492
+ /**
493
+ * Client plugin body: register the connection toggle over the command channel
494
+ * and the QQ Bot settings page over the settings wire face.
495
+ * @param ctx - client root context.
496
+ */
497
+ function apply(ctx) {
498
+ ctx.effect(() => ctx.locale.register(NS, {
499
+ zh,
500
+ en
501
+ }), "ui-qqbot: dictionaries");
502
+ const bound = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)({ sessionId: null });
503
+ ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
504
+ name: "conversation.input.left",
505
+ id: "qqbot",
506
+ order: 90,
507
+ locale: NS,
508
+ inject: (sessionId) => ({
509
+ hooks: { qqbotBound: bound },
510
+ toggle: async () => {
511
+ const active = bound.getSnapshot().sessionId === sessionId;
512
+ const line = active ? "/qqbot off" : "/qqbot on";
513
+ const result = await ctx.remote.commands.execute(sessionId, line);
514
+ if (!result.ok) return `${result.error.message} (${result.error.code})`;
515
+ if (result.value === void 0) return `unknown command: ${line}`;
516
+ bound.set({ sessionId: active ? null : sessionId });
517
+ return null;
518
+ }
519
+ })
520
+ }, QqbotToggle));
521
+ const settings = new QqbotSettingsController(ctx.get("connection").api);
522
+ ctx.effect(() => {
523
+ const disposers = [ctx.remote.$on("settings/document-updated", (ns) => {
524
+ if (ns === "qqbot") refreshIfLoaded(settings);
525
+ }), ctx.on("connection/reset", () => {
526
+ refreshIfLoaded(settings);
527
+ })];
528
+ settings.load();
529
+ return () => {
530
+ for (const dispose of disposers) dispose();
531
+ };
532
+ }, "ui-qqbot: settings transport");
533
+ const t = ctx.locale.bind(NS);
534
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
535
+ name: "settings.section",
536
+ id: "qqbot",
537
+ order: 20,
538
+ label: () => t("settings.nav"),
539
+ locale: NS,
540
+ inject: () => ({
541
+ hooks: {
542
+ qqbotSettings: settings.store,
543
+ qqbotBound: bound
544
+ },
545
+ save: (patch) => settings.save(patch)
546
+ })
547
+ }, QqbotSettingsSection));
548
+ }
549
+ //#endregion
550
+ exports.apply = apply;
551
+ exports.inject = inject;
552
+ return module.exports;
553
+ }
554
+ });
555
+
556
+ //# sourceMappingURL=client.js.map