dsh-qqchat 0.2.0 → 0.2.1

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.en.md CHANGED
@@ -31,6 +31,7 @@
31
31
  | --- | --- | --- |
32
32
  | QQ group and direct chat | Receives group and C2C messages | One isolated DSH Session per group or direct peer |
33
33
  | QR connection | Binds an official QQ Bot from **Settings -> QQ Chat** | Cancel the binding and scan again when needed |
34
+ | Manual connection | Connects with AppID and AppSecret from the settings page | Useful for headless servers or QR failures; the secret is sent only to the Host |
34
35
  | Group receive modes | Auto reply, Mention only or Silent record | Silent record stores messages without waking the Agent |
35
36
  | Message formats | Smart, Markdown or Plain compatibility | Group and direct-chat formats are configured separately; groups default to Plain compatibility |
36
37
  | Direct-chat streaming | Optionally uses QQ's official streaming message API for private chats | Private chats only; Plain compatibility disables it automatically; some clients may not display streaming |
@@ -80,6 +81,8 @@
80
81
  4. Authorize the desired official Bot.
81
82
  5. The Gateway starts automatically. Use **Cancel connection** before scanning again if you need to bind another Bot.
82
83
 
84
+ If QR authorization is inconvenient, choose **Manual configuration** and enter the AppID and AppSecret from the QQ Open Platform. Select the sandbox environment when needed. The plugin validates the credentials before saving them and starting the Gateway.
85
+
83
86
  The Host decrypts and stores the AppSecret in the local plugin database. The temporary QR AES key never travels through the browser Client RPC.
84
87
 
85
88
  ### Chat in QQ
@@ -115,7 +118,7 @@ npx @deepseek-ai/dsh --profile web
115
118
  Before npm publication, install the branch directly from GitHub:
116
119
 
117
120
  ```bash
118
- npx @deepseek-ai/dsh plugin --profile qqchat add @deepseek-ai/dsh-web-app "git+ssh://git@github.com/Coffeiz/dsh-qqchat.git#agent/typescript-migration"
121
+ npx @deepseek-ai/dsh plugin --profile qqchat add @deepseek-ai/dsh-web-app dsh-qqchat
119
122
  npx @deepseek-ai/dsh --profile qqchat
120
123
  ```
121
124
 
@@ -204,7 +207,7 @@ QQ conversations use DSH's default Conversation UI:
204
207
  ## Notes
205
208
 
206
209
  - An official QQ Bot is required; personal QQ account login is not supported.
207
- - `0.2.0` is still being refined; test with a dedicated Bot and group first.
210
+ - `0.2.1` is still being refined; test with a dedicated Bot and group first.
208
211
  - Rich media, full QQ emoji handling and some complex message formats may require compatibility mode.
209
212
  - Uninstalling the plugin does not automatically delete saved chat or memory data.
210
213
 
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  <a href="./docs/ARCHITECTURE.md">架构说明</a>
17
17
  </p>
18
18
 
19
- <p><em>dsh-qqchat 是 DeepSeek Harness(DSH)的 QQ 官方 Bot 插件,支持 QQ 群聊、私聊、会话隔离、记忆系统和 DSH 原生命令。</em></p>
19
+ <p><em>dsh-qqchat 是 DeepSeek Harness(DSH)的 QQ Bot 插件,支持 QQ 群聊、私聊、会话隔离、记忆系统和 DSH 原生命令。</em></p>
20
20
 
21
21
  > 本项目是一个 Vibe Coding 项目,代码质量和工程完整性可能需要改进。如果你发现问题或有更好的实现建议,欢迎提交 [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) 或 [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls)。
22
22
 
@@ -26,11 +26,12 @@
26
26
 
27
27
  | 功能 | 说明 | 备注 |
28
28
  | --- | --- | --- |
29
- | QQ 群聊与私聊 | 接收 QQ 官方 Bot 的群聊和私聊消息 | 每个群、每个私聊对象对应独立 DSH 会话 |
29
+ | QQ 群聊与私聊 | 接收 QQ Bot 的群聊和私聊消息 | 每个群、每个私聊对象对应独立 DSH 会话 |
30
30
  | 扫码连接 | 在 **设置 → QQ Chat** 中扫码绑定 QQ Bot | 支持取消连接后重新绑定 |
31
+ | 手动连接 | 在设置页填写 AppID 和 AppSecret 连接 QQ Bot | 适合无手机、服务器或扫码失败场景;密钥只提交到 Host |
31
32
  | 断线重连 | QQ Gateway 断线后自动恢复连接 | 无需重复扫码 |
32
33
  | 群聊回复模式 | 自动回应、@回复、静默记录 | 可在设置页随时切换 |
33
- | 消息格式兼容 | 智能兼容、Markdown、纯文本兼容 | 群聊和私聊可分别设置;群聊默认纯文本兼容 |
34
+ | 消息格式兼容 | 智能兼容、Markdown、纯文本兼容 | 可兼容旧版本QQ的群友们! |
34
35
  | 私聊流式回复 | 私聊可选用 QQ 官方流式消息接口逐步更新回复 | 仅适用于私聊;纯文本兼容模式自动关闭;部分客户端可能不可见 |
35
36
  | 消息引用与 @ | 支持引用消息和 QQ @消息 | @用户优先显示用户名 |
36
37
  | 会话隔离 | QQ 群聊和私聊分别使用独立会话 | 不同群、不同用户的上下文互相隔离 |
@@ -90,7 +91,7 @@ npx @deepseek-ai/dsh --profile web
90
91
  当前开发分支也可以直接从 GitHub 安装:
91
92
 
92
93
  ```bash
93
- npx @deepseek-ai/dsh plugin --profile qqchat add @deepseek-ai/dsh-web-app "git+ssh://git@github.com/Coffeiz/dsh-qqchat.git#agent/typescript-migration"
94
+ npx @deepseek-ai/dsh plugin --profile qqchat add @deepseek-ai/dsh-web-app dsh-qqchat
94
95
  npx @deepseek-ai/dsh --profile qqchat
95
96
  ```
96
97
 
@@ -102,6 +103,8 @@ npx @deepseek-ai/dsh --profile qqchat
102
103
  4. 使用手机 QQ 扫描二维码,并选择要授权的官方 Bot。
103
104
  5. 授权完成后,QQ Gateway 会自动连接。
104
105
 
106
+ 如果无法扫码,也可以选择 **手动配置**,填写 QQ 开放平台的 AppID 和 AppSecret,并按需选择沙箱环境。插件会先验证凭据,验证成功后再保存并启动 Gateway。
107
+
105
108
  连接成功后,QQ Chat 的设置和对话会出现在 DSH 中。
106
109
 
107
110
  ## 群聊接收方式
@@ -188,7 +191,7 @@ QQ 对话会使用 DSH 的默认聊天界面显示:
188
191
  ## 注意事项
189
192
 
190
193
  - 需要 QQ 官方 Bot,不支持个人 QQ 账号登录。
191
- - `0.2.0` 仍在持续完善,建议先在测试 Bot 和测试群中使用。
194
+ - `0.2.1` 仍在持续完善,建议先在测试 Bot 和测试群中使用。
192
195
  - 富媒体、完整 QQ 表情和部分复杂消息格式可能需要使用兼容模式。
193
196
  - 卸载插件不会自动删除已经保存的聊天和记忆数据。
194
197
 
@@ -15,6 +15,10 @@ export function QQSettings({ rpc }: { rpc: Rpc }) {
15
15
  const [members, setMembers] = useState<KnownMember[]>(() => qqSettingsCache?.members || [])
16
16
  const [loading, setLoading] = useState(() => qqSettingsCache === undefined)
17
17
  const [auth, setAuth] = useState<{ taskId: string; qrDataUrl: string } | null>(null)
18
+ const [connectionMode, setConnectionMode] = useState<'qr' | 'manual'>('qr')
19
+ const [manualAppId, setManualAppId] = useState('')
20
+ const [manualAppSecret, setManualAppSecret] = useState('')
21
+ const [manualSandbox, setManualSandbox] = useState(false)
18
22
  const [logs, setLogs] = useState<PluginLog[] | null>(null)
19
23
  const [busy, setBusy] = useState(false)
20
24
  const [error, setError] = useState('')
@@ -78,6 +82,15 @@ export function QQSettings({ rpc }: { rpc: Rpc }) {
78
82
  setBusy(true)
79
83
  try { setAuth(await call<{ taskId: string; qrDataUrl: string }>(rpc, 'auth/start')); setError('') } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setBusy(false) }
80
84
  }
85
+ const connectManual = async () => {
86
+ setBusy(true)
87
+ try {
88
+ await call(rpc, 'account/manual-connect', { appId: manualAppId, appSecret: manualAppSecret, sandbox: manualSandbox })
89
+ setManualAppSecret('')
90
+ await refresh()
91
+ setError('')
92
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setBusy(false) }
93
+ }
81
94
  const disconnect = async () => {
82
95
  if (!account) return
83
96
  setBusy(true)
@@ -128,7 +141,24 @@ export function QQSettings({ rpc }: { rpc: Rpc }) {
128
141
  h('div', { className: 'qqStatus' }, loading ? '加载中…' : account ? (account.gatewayStatus === 'online' ? '已连接' : '已授权') : '未连接', account && h(Button, { size: 'sm', variant: 'outline', disabled: busy, onClick: () => void disconnect() }, '取消连接')))
129
142
 
130
143
  if (loading) return h('div', { className: 'qqs' }, head, h('div', { className: 'qqMuted' }, '正在读取 QQ Chat 设置…'))
131
- if (!account) return h('div', { className: 'qqs' }, head, error && h('div', { className: 'qqError' }, error), h('div', { className: 'qqConnect' }, h('div', { className: 'qqsTitle' }, auth ? '使用 QQ 扫码授权' : '连接 QQ Bot'), h('div', { className: 'qqMuted' }, '扫码和凭据解密都在 DSH Host 侧完成。'), auth && h('div', { className: 'qqQr' }, h('img', { src: auth.qrDataUrl, alt: 'QQ 授权二维码' })), h(Button, { variant: 'primary', disabled: busy, onClick: () => void startAuth() }, auth ? '重新生成二维码' : '扫码连接')))
144
+ if (!account) {
145
+ const modeButtons = h('div', { className: 'qqChoiceButtons' },
146
+ h(Button, { size: 'sm', variant: connectionMode === 'qr' ? 'primary' : 'outline', onClick: () => { setConnectionMode('qr'); setError('') } }, '扫码连接'),
147
+ h(Button, { size: 'sm', variant: connectionMode === 'manual' ? 'primary' : 'outline', onClick: () => { setConnectionMode('manual'); setAuth(null); setError('') } }, '手动配置'))
148
+ const qrBody = h('div', { className: 'qqConnect' },
149
+ h('div', { className: 'qqsTitle' }, auth ? '使用 QQ 扫码授权' : '连接 QQ Bot'),
150
+ h('div', { className: 'qqMuted' }, '使用手机 QQ 扫描官方 Bot 授权二维码。扫码和凭据解密都在 DSH Host 侧完成。'),
151
+ auth && h('div', { className: 'qqQr' }, h('img', { src: auth.qrDataUrl, alt: 'QQ 授权二维码' })),
152
+ h(Button, { variant: 'primary', disabled: busy, onClick: () => void startAuth() }, auth ? '重新生成二维码' : '生成二维码'))
153
+ const manualBody = h('div', { className: 'qqConnect qqManualConnect' },
154
+ h('div', { className: 'qqsTitle' }, '手动配置 QQ Bot'),
155
+ h('div', { className: 'qqMuted' }, '填写 QQ 开放平台创建的 AppID 和 AppSecret。密钥只提交到 DSH Host,不会显示在会话或日志中。'),
156
+ h(Input, { value: manualAppId, placeholder: 'AppID', onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => setManualAppId(event.target.value) }),
157
+ h(Input, { type: 'password', value: manualAppSecret, placeholder: 'AppSecret', onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => setManualAppSecret(event.target.value) }),
158
+ h('label', { className: 'qqCheckRow' }, h('input', { type: 'checkbox', checked: manualSandbox, onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => setManualSandbox(event.target.checked) }), h('span', null, '使用沙箱环境')),
159
+ h(Button, { variant: 'primary', disabled: busy || !manualAppId.trim() || !manualAppSecret.trim(), onClick: () => void connectManual() }, busy ? '验证并连接中…' : '验证并连接'))
160
+ return h('div', { className: 'qqs' }, head, error && h('div', { className: 'qqError' }, error), modeButtons, connectionMode === 'qr' ? qrBody : manualBody)
161
+ }
132
162
  if (!settings) return h('div', { className: 'qqs' }, head, h('div', { className: 'qqMuted' }, '正在读取设置…'))
133
163
 
134
164
  const receiveButtons = h('div', { className: 'qqChoiceButtons' },
package/docs/SECURITY.md CHANGED
@@ -6,7 +6,7 @@ QQChat 同时接触 QQ 凭据、用户身份、群消息和 DSH Session。安全
6
6
 
7
7
  ## 凭据边界
8
8
 
9
- - AppSecret、QQ Token、扫码 AES 临时 key 只存在 Host。
9
+ - AppSecret、QQ Token、扫码 AES 临时 key 只存在 Host;手动连接表单只通过 loopback RPC 提交 AppSecret,验证失败时不保存新凭据。
10
10
  - 浏览器只通过受限 RPC 获取状态和已经脱敏的展示数据。
11
11
  - 凭据不写入 URL、日志、前端状态、测试 fixture 或 Git。
12
12
  - SQLite 数据目录默认位于 `$DSH_HOME/plugins/dsh-qqchat/`,应由本地用户权限保护。
package/lib/client.js CHANGED
@@ -29,6 +29,10 @@ function QQSettings({ rpc }) {
29
29
  const [members, setMembers] = (0, shared_cjs_1.useState)(() => qqSettingsCache?.members || []);
30
30
  const [loading, setLoading] = (0, shared_cjs_1.useState)(() => qqSettingsCache === undefined);
31
31
  const [auth, setAuth] = (0, shared_cjs_1.useState)(null);
32
+ const [connectionMode, setConnectionMode] = (0, shared_cjs_1.useState)('qr');
33
+ const [manualAppId, setManualAppId] = (0, shared_cjs_1.useState)('');
34
+ const [manualAppSecret, setManualAppSecret] = (0, shared_cjs_1.useState)('');
35
+ const [manualSandbox, setManualSandbox] = (0, shared_cjs_1.useState)(false);
32
36
  const [logs, setLogs] = (0, shared_cjs_1.useState)(null);
33
37
  const [busy, setBusy] = (0, shared_cjs_1.useState)(false);
34
38
  const [error, setError] = (0, shared_cjs_1.useState)('');
@@ -125,6 +129,21 @@ function QQSettings({ rpc }) {
125
129
  setBusy(false);
126
130
  }
127
131
  };
132
+ const connectManual = async () => {
133
+ setBusy(true);
134
+ try {
135
+ await (0, shared_cjs_1.call)(rpc, 'account/manual-connect', { appId: manualAppId, appSecret: manualAppSecret, sandbox: manualSandbox });
136
+ setManualAppSecret('');
137
+ await refresh();
138
+ setError('');
139
+ }
140
+ catch (err) {
141
+ setError(err instanceof Error ? err.message : String(err));
142
+ }
143
+ finally {
144
+ setBusy(false);
145
+ }
146
+ };
128
147
  const disconnect = async () => {
129
148
  if (!account)
130
149
  return;
@@ -184,8 +203,12 @@ function QQSettings({ rpc }) {
184
203
  const head = (0, shared_cjs_1.h)('div', { className: 'qqsHead' }, (0, shared_cjs_1.h)('div', null, (0, shared_cjs_1.h)('div', { className: 'qqsTitle' }, 'QQ Chat'), (0, shared_cjs_1.h)('div', { className: 'qqMuted' }, 'QQ Bot 接收、兼容与权限设置')), (0, shared_cjs_1.h)('div', { className: 'qqStatus' }, loading ? '加载中…' : account ? (account.gatewayStatus === 'online' ? '已连接' : '已授权') : '未连接', account && (0, shared_cjs_1.h)(shared_cjs_1.Button, { size: 'sm', variant: 'outline', disabled: busy, onClick: () => void disconnect() }, '取消连接')));
185
204
  if (loading)
186
205
  return (0, shared_cjs_1.h)('div', { className: 'qqs' }, head, (0, shared_cjs_1.h)('div', { className: 'qqMuted' }, '正在读取 QQ Chat 设置…'));
187
- if (!account)
188
- return (0, shared_cjs_1.h)('div', { className: 'qqs' }, head, error && (0, shared_cjs_1.h)('div', { className: 'qqError' }, error), (0, shared_cjs_1.h)('div', { className: 'qqConnect' }, (0, shared_cjs_1.h)('div', { className: 'qqsTitle' }, auth ? '使用 QQ 扫码授权' : '连接 QQ Bot'), (0, shared_cjs_1.h)('div', { className: 'qqMuted' }, '扫码和凭据解密都在 DSH Host 侧完成。'), auth && (0, shared_cjs_1.h)('div', { className: 'qqQr' }, (0, shared_cjs_1.h)('img', { src: auth.qrDataUrl, alt: 'QQ 授权二维码' })), (0, shared_cjs_1.h)(shared_cjs_1.Button, { variant: 'primary', disabled: busy, onClick: () => void startAuth() }, auth ? '重新生成二维码' : '扫码连接')));
206
+ if (!account) {
207
+ const modeButtons = (0, shared_cjs_1.h)('div', { className: 'qqChoiceButtons' }, (0, shared_cjs_1.h)(shared_cjs_1.Button, { size: 'sm', variant: connectionMode === 'qr' ? 'primary' : 'outline', onClick: () => { setConnectionMode('qr'); setError(''); } }, '扫码连接'), (0, shared_cjs_1.h)(shared_cjs_1.Button, { size: 'sm', variant: connectionMode === 'manual' ? 'primary' : 'outline', onClick: () => { setConnectionMode('manual'); setAuth(null); setError(''); } }, '手动配置'));
208
+ const qrBody = (0, shared_cjs_1.h)('div', { className: 'qqConnect' }, (0, shared_cjs_1.h)('div', { className: 'qqsTitle' }, auth ? '使用 QQ 扫码授权' : '连接 QQ Bot'), (0, shared_cjs_1.h)('div', { className: 'qqMuted' }, '使用手机 QQ 扫描官方 Bot 授权二维码。扫码和凭据解密都在 DSH Host 侧完成。'), auth && (0, shared_cjs_1.h)('div', { className: 'qqQr' }, (0, shared_cjs_1.h)('img', { src: auth.qrDataUrl, alt: 'QQ 授权二维码' })), (0, shared_cjs_1.h)(shared_cjs_1.Button, { variant: 'primary', disabled: busy, onClick: () => void startAuth() }, auth ? '重新生成二维码' : '生成二维码'));
209
+ const manualBody = (0, shared_cjs_1.h)('div', { className: 'qqConnect qqManualConnect' }, (0, shared_cjs_1.h)('div', { className: 'qqsTitle' }, '手动配置 QQ Bot'), (0, shared_cjs_1.h)('div', { className: 'qqMuted' }, '填写 QQ 开放平台创建的 AppID 和 AppSecret。密钥只提交到 DSH Host,不会显示在会话或日志中。'), (0, shared_cjs_1.h)(shared_cjs_1.Input, { value: manualAppId, placeholder: 'AppID', onChange: (event) => setManualAppId(event.target.value) }), (0, shared_cjs_1.h)(shared_cjs_1.Input, { type: 'password', value: manualAppSecret, placeholder: 'AppSecret', onChange: (event) => setManualAppSecret(event.target.value) }), (0, shared_cjs_1.h)('label', { className: 'qqCheckRow' }, (0, shared_cjs_1.h)('input', { type: 'checkbox', checked: manualSandbox, onChange: (event) => setManualSandbox(event.target.checked) }), (0, shared_cjs_1.h)('span', null, '使用沙箱环境')), (0, shared_cjs_1.h)(shared_cjs_1.Button, { variant: 'primary', disabled: busy || !manualAppId.trim() || !manualAppSecret.trim(), onClick: () => void connectManual() }, busy ? '验证并连接中…' : '验证并连接'));
210
+ return (0, shared_cjs_1.h)('div', { className: 'qqs' }, head, error && (0, shared_cjs_1.h)('div', { className: 'qqError' }, error), modeButtons, connectionMode === 'qr' ? qrBody : manualBody);
211
+ }
189
212
  if (!settings)
190
213
  return (0, shared_cjs_1.h)('div', { className: 'qqs' }, head, (0, shared_cjs_1.h)('div', { className: 'qqMuted' }, '正在读取设置…'));
191
214
  const receiveButtons = (0, shared_cjs_1.h)('div', { className: 'qqChoiceButtons' }, ...['auto', 'mention', 'silent'].map(mode => (0, shared_cjs_1.h)(shared_cjs_1.Button, {
@@ -32,6 +32,7 @@ export declare class QQChatRuntime {
32
32
  readAttachment(sessionId: string, attachmentId: string): Promise<Record<string, unknown>>;
33
33
  startGateway(account: AccountRow | undefined): void;
34
34
  restartGateway(accountId: number): Promise<void>;
35
+ connectManual(appId: string, appSecret: string, sandbox?: boolean): Promise<AccountRow>;
35
36
  private onIncoming;
36
37
  private chatRow;
37
38
  private flushOutbox;
@@ -176,6 +176,29 @@ export class QQChatRuntime {
176
176
  if (account?.enabled)
177
177
  this.startGateway(account);
178
178
  }
179
+ async connectManual(appId, appSecret, sandbox = this.config.sandbox) {
180
+ const normalizedAppId = appId.trim();
181
+ const normalizedSecret = appSecret.trim();
182
+ if (!normalizedAppId || !normalizedSecret)
183
+ throw new Error('AppID 和 AppSecret 不能为空');
184
+ if (normalizedAppId.length > 256 || normalizedSecret.length > 512)
185
+ throw new Error('QQ Bot 凭据长度无效');
186
+ const existing = this.db.accountByAppId(normalizedAppId);
187
+ const candidate = {
188
+ ...(existing || { id: 0, bot_user_id: null, enabled: 1, gateway_status: 'offline', gateway_last_error: null, created_at: 0, updated_at: 0 }),
189
+ app_id: normalizedAppId, app_secret: normalizedSecret, sandbox: sandbox ? 1 : 0,
190
+ };
191
+ this.api.clearToken(Number(candidate.id));
192
+ try {
193
+ await this.api.gatewayUrl(candidate);
194
+ }
195
+ catch {
196
+ throw new Error('QQ Bot 凭据验证失败,请检查 AppID、AppSecret 和沙箱环境设置');
197
+ }
198
+ const account = this.db.upsertAccount(normalizedAppId, normalizedSecret, sandbox);
199
+ await this.restartGateway(Number(account.id));
200
+ return account;
201
+ }
179
202
  async onIncoming(message) {
180
203
  const account = this.db.accountById(message.accountId);
181
204
  if (!account || !account.enabled)
@@ -27,6 +27,11 @@ export function createQQChatRpc(runtime) {
27
27
  }
28
28
  return ok(result);
29
29
  }
30
+ case 'account/manual-connect': {
31
+ const record = asRecord(payload);
32
+ const account = await runtime.connectManual(requireString(payload, 'appId'), requireString(payload, 'appSecret'), record?.sandbox === true);
33
+ return ok({ account: publicAccount(account) });
34
+ }
30
35
  case 'account/reconnect': {
31
36
  const id = requireNumber(payload, 'accountId');
32
37
  runtime.db.setAccountEnabled(id, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qqchat",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -160,6 +160,27 @@ export class QQChatRuntime {
160
160
  if (account?.enabled) this.startGateway(account)
161
161
  }
162
162
 
163
+ async connectManual(appId: string, appSecret: string, sandbox = this.config.sandbox): Promise<AccountRow> {
164
+ const normalizedAppId = appId.trim()
165
+ const normalizedSecret = appSecret.trim()
166
+ if (!normalizedAppId || !normalizedSecret) throw new Error('AppID 和 AppSecret 不能为空')
167
+ if (normalizedAppId.length > 256 || normalizedSecret.length > 512) throw new Error('QQ Bot 凭据长度无效')
168
+ const existing = this.db.accountByAppId(normalizedAppId)
169
+ const candidate = {
170
+ ...(existing || { id: 0, bot_user_id: null, enabled: 1 as const, gateway_status: 'offline' as const, gateway_last_error: null, created_at: 0, updated_at: 0 }),
171
+ app_id: normalizedAppId, app_secret: normalizedSecret, sandbox: sandbox ? 1 as const : 0 as const,
172
+ }
173
+ this.api.clearToken(Number(candidate.id))
174
+ try {
175
+ await this.api.gatewayUrl(candidate)
176
+ } catch {
177
+ throw new Error('QQ Bot 凭据验证失败,请检查 AppID、AppSecret 和沙箱环境设置')
178
+ }
179
+ const account = this.db.upsertAccount(normalizedAppId, normalizedSecret, sandbox)
180
+ await this.restartGateway(Number(account.id))
181
+ return account
182
+ }
183
+
163
184
  private async onIncoming(message: QQNormalizedMessage): Promise<void> {
164
185
  const account = this.db.accountById(message.accountId)
165
186
  if (!account || !account.enabled) return
@@ -46,6 +46,15 @@ export function createQQChatRpc(runtime: QQChatRuntime): ConnectionRpcHandler {
46
46
  }
47
47
  return ok(result)
48
48
  }
49
+ case 'account/manual-connect': {
50
+ const record = asRecord(payload)
51
+ const account = await runtime.connectManual(
52
+ requireString(payload, 'appId'),
53
+ requireString(payload, 'appSecret'),
54
+ record?.sandbox === true,
55
+ )
56
+ return ok({ account: publicAccount(account) })
57
+ }
49
58
  case 'account/reconnect': {
50
59
  const id = requireNumber(payload, 'accountId')
51
60
  runtime.db.setAccountEnabled(id, true)