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 +5 -2
- package/README.md +8 -5
- package/client-src/settings.cts +31 -1
- package/docs/SECURITY.md +1 -1
- package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
- package/lib/client.js +25 -2
- package/lib/session/runtime.d.ts +1 -0
- package/lib/session/runtime.js +23 -0
- package/lib/transport/rpc.js +5 -0
- package/package.json +1 -1
- package/src/session/runtime.ts +21 -0
- package/src/transport/rpc.ts +9 -0
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
194
|
+
- `0.2.1` 仍在持续完善,建议先在测试 Bot 和测试群中使用。
|
|
192
195
|
- 富媒体、完整 QQ 表情和部分复杂消息格式可能需要使用兼容模式。
|
|
193
196
|
- 卸载插件不会自动删除已经保存的聊天和记忆数据。
|
|
194
197
|
|
package/client-src/settings.cts
CHANGED
|
@@ -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)
|
|
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/`,应由本地用户权限保护。
|
|
Binary file
|
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
|
-
|
|
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, {
|
package/lib/session/runtime.d.ts
CHANGED
|
@@ -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;
|
package/lib/session/runtime.js
CHANGED
|
@@ -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)
|
package/lib/transport/rpc.js
CHANGED
|
@@ -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
package/src/session/runtime.ts
CHANGED
|
@@ -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
|
package/src/transport/rpc.ts
CHANGED
|
@@ -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)
|