dsh-qq-tools 0.7.0 → 0.8.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  给 DSH(DeepSeek Harness)里的大肥鱼会话用的 QQ 群/私聊工具,走 OneBot 11 HTTP + WebSocket。系统表情在 SnowLuma / NapCat 上都能发;收藏表情用 SnowLuma 的自定义表情扩展接口,和 QQ 客户端里的收藏夹是同一份数据。
4
4
 
5
- 8 个工具各自是一个独立的 cordis 组件,在 DSH 插件页的「包含的组件」里可以逐个开关:
5
+ 8 个工具各自是一个独立的 cordis 组件,外加一个负责选择挂载会话的设置组件,在 DSH 插件页的「包含的组件」里可以逐个开关:
6
6
 
7
7
  | 组件 | 工具 | 说明 |
8
8
  | --- | --- | --- |
@@ -14,15 +14,18 @@
14
14
  | 收藏表情 | `qq_list_faces` / `qq_favorite_face` | 看/管理 QQ 账号收藏夹(SnowLuma):收藏图片、取消收藏、改备注、移到最前 |
15
15
  | 私聊 | `qq_list_friends` / `qq_get_private_history` / `qq_send_private_message` | 好友列表、私聊记录、发私聊消息;同时负责私聊消息唤醒 |
16
16
  | 戳一戳 | `qq_send_poke` | 戳人;同时负责接收戳一戳事件 |
17
+ | 挂载会话 | — | 插件页里的「配置」页面:下拉选择 QQ 消息唤醒哪个 DSH 会话 |
17
18
 
18
- 关掉「发群消息」会同时停掉 @ 提醒轮询,关掉「私聊」会同时停掉私聊消息唤醒,关掉「戳一戳」会同时停掉戳一戳通知;三个只读工具互不影响。
19
+ 关掉「发群消息」会同时停掉 @ 提醒轮询,关掉「私聊」会同时停掉私聊消息唤醒,关掉「戳一戳」会同时停掉戳一戳通知;三个只读工具互不影响。关掉「挂载会话」只会让配置页面不可用,已经写进配置的 `targetSession` 仍然生效。
19
20
 
20
21
  ## 目录结构
21
22
 
22
23
  ```
23
- cordis.patch.yml 8 个组件行
24
+ cordis.patch.yml 9 个组件行
24
25
  lib/shared.js 共享核心:配置、OneBot、挂载、@ 轮询、私聊唤醒、戳一戳、表情、观察窗
25
- lib/index.js 兼容入口:一个插件挂全部工具(bundle 不使用)
26
+ lib/client.js 浏览器半侧:插件页里的挂载会话下拉框
27
+ lib/index.js 包根 carrier(承载浏览器半侧和挂载会话接口),也是旧的全量入口
28
+ locale/ 包根行在插件页显示的标题和说明
26
29
  tools/qq-list-groups/ 组件入口 + locale
27
30
  tools/qq-get-group-history/
28
31
  tools/qq-get-group-members/
@@ -33,7 +36,19 @@ tools/qq-private-chat/
33
36
  tools/qq-send-poke/
34
37
  ```
35
38
 
36
- 每个 `tools/<组件>/index.js` 只做一件事:把自己的工具挂到目标会话。`locale/{en,zh}.json` 提供插件页显示的标题和说明。
39
+ 「挂载会话」用的是包根行(模块名 `dsh-qq-tools`,所以插件卡片名仍是 `dsh-qq-tools`):DSH 只从包根发现 `dsh.client`,子路径行不会扫描;`config.carrier: true` 让它只承载设置接口,不会重复挂一遍工具。
40
+
41
+ 每个 `tools/<组件>/index.js` 只做一件事:把自己的工具挂到目标会话。`locale/{en,zh}.json` 提供插件页显示的标题和说明;左侧栏入口和插件行配置页在 `lib/client.js` 里注册。
42
+
43
+ ## 选择挂载会话
44
+
45
+ 左侧栏的「挂载会话」入口(也可以在「插件 → dsh-qq-tools → 挂载会话 → 配置」里打开)会列出当前 DSH 里的会话(标题 + 短 ID + 运行状态)。选中后保存,配置写回 `~/.dsh/qq-tools-config.json` 的 `targetSession`,各组件在 3 秒内重新挂到这个会话。
46
+
47
+ - 归档会话和子会话不会出现在列表里;如果当前目标后来被归档,列表会把它标成「当前目标(已归档)」。
48
+ - 工具只注册在选中的会话上,其他会话看不到 QQ 工具,也不会被 QQ 消息唤醒。
49
+ - 选「不挂载」或留空会卸下所有工具并停止唤醒。
50
+ - 保存时不校验会话是否存活:会话暂时关掉不会把配置清掉,等它重新打开后会自动挂上。
51
+ - 这个页面依赖「挂载会话」组件;关掉该组件后接口不可用,但已保存的 `targetSession` 仍然生效,也可以直接改配置文件。
37
52
 
38
53
  ## 配置
39
54
 
@@ -59,7 +74,7 @@ tools/qq-send-poke/
59
74
  ```
60
75
 
61
76
  - `napCat` 是 OneBot 11 HTTP 地址(SnowLuma 默认 `http://127.0.0.1:3000`,NapCat 也一样);`wsUrl` 是 WebSocket 事件地址(默认 `ws://127.0.0.1:3001`)。
62
- - `targetSession` 是目标会话 ID;留空时工具不会挂到任何会话。
77
+ - `targetSession` 是目标会话 ID;可以在插件页「挂载会话 → 配置」里下拉选择,也可以直接改这个文件;留空时工具不会挂到任何会话。
63
78
  - `groups` 是允许看/发的群;空数组表示不限制。
64
79
  - `mentionEnabled` / `pokeEnabled` / `privateEnabled` 分别是 @ 轮询、戳一戳事件、私聊唤醒的总闸,对应组件关掉时它们也不会运行。
65
80
  - `privateUsers` 是允许唤醒和私聊工具的 QQ 白名单;空数组表示不限制。非空时,不在列表里的私聊消息不会唤醒,`qq_list_friends` 也不会列出他们。
@@ -84,7 +99,7 @@ tools/qq-send-poke/
84
99
 
85
100
  私聊和群聊在程序里是分开的两条路径:群聊靠轮询 @,私聊靠 WebSocket 的 `message_type=private` 事件。私聊没有观察窗,每收到一条私聊消息都会直接唤醒目标会话。
86
101
 
87
- - 唤醒提示里会带发送者昵称、QQ 号、`message_id` 和文字;如果消息里有图片,也会把图片一起给模型看。
102
+ - 唤醒提示里会带发送者昵称、QQ 号、`message_id` 和文字;如果消息里有图片,也会把图片一起给模型看(GIF 会拆成首/中/尾最多 3 帧,和群聊记录的处理一致)。
88
103
  - 模型用 `qq_get_private_history` 看和对方的最近记录,用 `qq_send_private_message` 回文字,用 `qq_send_face` 回表情。
89
104
  - `qq_list_friends` 列好友,可以按昵称、备注或 QQ 号搜;`privateUsers` 非空时只列白名单里的好友。
90
105
  - `qq_get_private_history` 的消息带 `#message_id`,可以传给 `qq_send_private_message` / `qq_send_face` 的 `reply_to` 做引用回复。
@@ -102,12 +117,20 @@ dsh plugin --profile <profile> add github:cyhddyx/dsh-qq-tools
102
117
 
103
118
  本插件是纯 JavaScript,没有构建步骤,所以不需要 `prepare` 脚本,也不需要给 pnpm 加 `allowBuilds` 授权。安装完成后重启 DSH,插件页里应该能看到 8 个组件,每个带开关。
104
119
 
105
- ### 从 npm 安装(发布后可用)
120
+ ### 从 npm 安装(推荐)
121
+
122
+ 已发布到 npm,装最新版:
106
123
 
107
124
  ```sh
108
125
  dsh plugin --profile <profile> add dsh-qq-tools
109
126
  ```
110
127
 
128
+ 需要固定版本时:
129
+
130
+ ```sh
131
+ dsh plugin --profile <profile> add dsh-qq-tools@0.8.0
132
+ ```
133
+
111
134
  ### 本地开发 / 自己改代码
112
135
 
113
136
  ```sh
@@ -118,7 +141,7 @@ dsh plugin --profile <profile> add link:/path/to/dsh-qq-tools
118
141
 
119
142
  ```sh
120
143
  pnpm pack
121
- dsh plugin --profile <profile> add ./dsh-qq-tools-0.7.0.tgz
144
+ dsh plugin --profile <profile> add ./dsh-qq-tools-0.8.0.tgz
122
145
  ```
123
146
 
124
147
  `dsh plugin` 会在安装后自动把这个包追加进 profile 的 `dsh.profile.bundles`,不用手改 `package.json`。卸载用:
@@ -132,8 +155,8 @@ dsh plugin --profile <profile> remove dsh-qq-tools
132
155
  - DSH 0.2.x(cordis 插件体系)
133
156
  - Node.js 22+(内置 `fetch` / `WebSocket`)
134
157
  - SnowLuma 提供 OneBot 11 HTTP 接口和 WebSocket 事件(收藏表情需要 SnowLuma;系统表情 NapCat 也能发)
135
- - 可选:`sharp`(GIF 帧拆分;没有也能用,GIF 会按静图处理)
158
+ - 可选:`sharp`(GIF 帧拆分)。会自动使用插件自己装的、`SHARP_PATH` 指定的,或 DSH 宿主自带的 sharp;都找不到时 GIF 按静图处理
136
159
 
137
160
  ---
138
161
 
139
- 版本:0.7.0
162
+ 版本:0.8.0
package/cordis.patch.yml CHANGED
@@ -1,10 +1,11 @@
1
- # DSH 大肥鱼 QQ 群/私聊工具:8 个工具各是一个独立组件,插件页里可以逐个开关
1
+ # DSH 大肥鱼 QQ 群/私聊工具:9 个组件,插件页里可以逐个开关
2
2
  # · 列出 QQ 群 / 看群聊天记录 / 看群成员 —— 只读工具
3
3
  # · 发群消息 —— 同时负责 @ 提醒与群后续轮询
4
4
  # · 发表情 —— 在群里或私聊里发系统表情或 QQ 收藏夹里的自定义表情
5
5
  # · 收藏表情 —— QQ 收藏夹(SnowLuma):qq_list_faces + qq_favorite_face
6
6
  # · 私聊 —— 好友列表 / 私聊记录 / 发私聊消息,同时负责私聊消息唤醒
7
7
  # · 戳一戳 —— 同时负责接收戳一戳事件
8
+ # · 挂载会话 —— 插件页里的「配置」页面,选择 QQ 消息唤醒哪个 DSH 会话
8
9
  - insert:
9
10
  - id: qq-tools-list-groups
10
11
  name: 'dsh-qq-tools/tools/qq-list-groups'
@@ -22,3 +23,8 @@
22
23
  name: 'dsh-qq-tools/tools/qq-private-chat'
23
24
  - id: qq-tools-send-poke
24
25
  name: 'dsh-qq-tools/tools/qq-send-poke'
26
+ # 包根行:DSH 只从包根发现 dsh.client,浏览器半侧和挂载会话接口都挂在这里。
27
+ - id: qq-tools-root
28
+ name: 'dsh-qq-tools'
29
+ config:
30
+ carrier: true
package/lib/client.js ADDED
@@ -0,0 +1,328 @@
1
+ // dsh-qq-tools 的浏览器半侧:左侧栏「挂载会话」入口 + 插件行配置页。
2
+ // 会话列表来自 slot 自带的 useSessions,归档集合来自 useWorkspaces;
3
+ // 读写走宿主半侧的 /api/dsh-qq-tools/target-session,最终仍然落在
4
+ // ~/.dsh/qq-tools-config.json 的 targetSession 字段上。
5
+ window.__ModuleLoader__.load({
6
+ id: 'dsh-qq-tools',
7
+ factory: (require) => {
8
+ var module = { exports: {} }
9
+ var exports = module.exports
10
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
11
+
12
+ const react = require('react')
13
+ const h = react.createElement
14
+
15
+ const NS = 'qq-tools'
16
+ const API = '/api/dsh-qq-tools/target-session'
17
+ const SLOT_KEY = 'dsh-qq-tools#qq-tools-root'
18
+ const PANEL_ID = 'qq-tools-target-session'
19
+ const PANEL_ORDER = 50
20
+ const EMPTY_IDS = []
21
+
22
+ const zh = {
23
+ summary: 'QQ 消息挂载会话',
24
+ sidebarLabel: '挂载会话',
25
+ panelTitle: '挂载会话',
26
+ description: '只有这里选中的 DSH 会话会收到 QQ 群 @、群后续、私聊和戳一戳;QQ 工具也只挂在这个会话上。保存后约 3 秒生效。',
27
+ none: '不挂载(关闭 QQ 唤醒)',
28
+ untitled: '未命名会话',
29
+ running: '(运行中)',
30
+ missing: '当前目标(会话未运行)',
31
+ archived: '当前目标(已归档)',
32
+ refresh: '刷新',
33
+ save: '保存',
34
+ saving: '保存中…',
35
+ saved: '已保存,约 3 秒内生效',
36
+ loading: '读取中…',
37
+ failed: '操作失败:',
38
+ current: '当前挂载:',
39
+ empty: '当前没有挂载任何会话。',
40
+ unavailable: '接口不可用。请先在插件页启用「挂载会话」组件。',
41
+ }
42
+
43
+ const en = {
44
+ summary: 'QQ target session',
45
+ sidebarLabel: 'Target session',
46
+ panelTitle: 'Target session',
47
+ description: 'Only the selected DSH session receives QQ mentions, follow-ups, private messages and pokes; QQ tools are mounted there too. Changes apply within about 3 seconds.',
48
+ none: 'Do not mount (disable QQ wake-ups)',
49
+ untitled: 'Untitled session',
50
+ running: ' (running)',
51
+ missing: 'Current target (not running)',
52
+ archived: 'Current target (archived)',
53
+ refresh: 'Refresh',
54
+ save: 'Save',
55
+ saving: 'Saving…',
56
+ saved: 'Saved; applies within about 3 seconds',
57
+ loading: 'Loading…',
58
+ failed: 'Request failed: ',
59
+ current: 'Mounted to: ',
60
+ empty: 'No session is mounted.',
61
+ unavailable: 'Endpoint unavailable. Enable the "Target session" component on the plugin page first.',
62
+ }
63
+
64
+ const inject = ['slots', 'locale']
65
+
66
+ function shortId(id) {
67
+ const s = String(id || '')
68
+ return s.length > 12 ? s.slice(0, 8) + '…' : s
69
+ }
70
+
71
+ // 宿主在插件行未启用时返回 404/HTML;先把文本读出来再解析,避免 r.json() 直接抛。
72
+ function readJsonResponse(response) {
73
+ return response.text().then((text) => {
74
+ let body = {}
75
+ try { body = text ? JSON.parse(text) : {} } catch (error) {}
76
+ return { ok: response.ok, body }
77
+ })
78
+ }
79
+
80
+ function TargetSessionCard(props) {
81
+ const t = props.t
82
+ const list = props.useSessions((state) => state)
83
+ const useWorkspaces = props.useWorkspaces || (() => EMPTY_IDS)
84
+ const archivedSessionIds = useWorkspaces((state) => (state && state.archivedSessionIds) || EMPTY_IDS) || EMPTY_IDS
85
+ const archived = react.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
86
+ const [current, setCurrent] = react.useState('')
87
+ const [selected, setSelected] = react.useState('')
88
+ const [liveIds, setLiveIds] = react.useState([])
89
+ const [phase, setPhase] = react.useState('loading')
90
+ const [detail, setDetail] = react.useState('')
91
+ const [loaded, setLoaded] = react.useState(false)
92
+
93
+ const load = react.useCallback(() => {
94
+ setPhase('loading')
95
+ setDetail('')
96
+ fetch(API, { headers: { accept: 'application/json' } })
97
+ .then(readJsonResponse)
98
+ .then(({ ok, body }) => {
99
+ if (!ok || !body || body.ok !== true) {
100
+ throw new Error((body && body.error) || t('unavailable'))
101
+ }
102
+ const value = typeof body.targetSession === 'string' ? body.targetSession : ''
103
+ setCurrent(value)
104
+ setSelected(value)
105
+ setLiveIds(Array.isArray(body.liveSessionIds) ? body.liveSessionIds : [])
106
+ setLoaded(true)
107
+ setPhase('ready')
108
+ })
109
+ .catch((error) => {
110
+ setDetail(String((error && error.message) || error))
111
+ setPhase('error')
112
+ })
113
+ }, [t])
114
+
115
+ react.useEffect(() => {
116
+ load()
117
+ }, [load])
118
+
119
+ const save = react.useCallback(() => {
120
+ setPhase('saving')
121
+ setDetail('')
122
+ fetch(API, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
125
+ body: JSON.stringify({ targetSession: selected }),
126
+ })
127
+ .then(readJsonResponse)
128
+ .then(({ ok, body }) => {
129
+ if (!ok || !body || body.ok !== true) {
130
+ throw new Error((body && body.error) || 'save failed')
131
+ }
132
+ const value = typeof body.targetSession === 'string' ? body.targetSession : ''
133
+ setCurrent(value)
134
+ setSelected(value)
135
+ setPhase('saved')
136
+ })
137
+ .catch((error) => {
138
+ setDetail(String((error && error.message) || error))
139
+ setPhase('error')
140
+ })
141
+ }, [selected])
142
+
143
+ const sessions = react.useMemo(() => {
144
+ const ids = Array.isArray(list && list.ids) ? list.ids : []
145
+ const byId = (list && list.byId) || {}
146
+ return ids.map((id) => byId[id]).filter(Boolean)
147
+ }, [list])
148
+
149
+ const options = [h('option', { key: '__none', value: '' }, t('none'))]
150
+ let selectedInList = false
151
+ for (const session of sessions) {
152
+ if (!session || !session.id) continue
153
+ if (session.origin === 'subagent') continue
154
+ if (archived.has(session.id)) continue
155
+ if (session.id === selected) selectedInList = true
156
+ const title = String(session.title || '').trim() || t('untitled')
157
+ const running = session.running === true || liveIds.indexOf(session.id) >= 0
158
+ options.push(h(
159
+ 'option',
160
+ { key: session.id, value: session.id },
161
+ title + ' · ' + shortId(session.id) + (running ? t('running') : ''),
162
+ ))
163
+ }
164
+ if (selected && !selectedInList) {
165
+ options.push(h(
166
+ 'option',
167
+ { key: '__current', value: selected },
168
+ (archived.has(selected) ? t('archived') : t('missing')) + ' · ' + shortId(selected),
169
+ ))
170
+ }
171
+
172
+ if (props.view === 'summary') {
173
+ return h('span', null, current ? t('current') + shortId(current) : t('empty'))
174
+ }
175
+
176
+ let statusText = ''
177
+ let statusColor = 'var(--dsw-alias-label-tertiary)'
178
+ if (phase === 'loading') statusText = t('loading')
179
+ else if (phase === 'saving') statusText = t('saving')
180
+ else if (phase === 'saved') {
181
+ statusText = t('saved')
182
+ statusColor = 'var(--dsw-alias-state-success-primary, #2f9e44)'
183
+ } else if (phase === 'error') {
184
+ statusText = t('failed') + (detail || t('unavailable'))
185
+ statusColor = 'var(--dsw-alias-state-error-primary, #d64545)'
186
+ } else {
187
+ statusText = current ? t('current') + shortId(current) : t('empty')
188
+ }
189
+
190
+ const disabled = !loaded || phase === 'saving'
191
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, [
192
+ h('p', {
193
+ key: 'desc',
194
+ style: { margin: 0, fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-secondary)' },
195
+ }, t('description')),
196
+ h('div', { key: 'row', style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } }, [
197
+ h('select', {
198
+ key: 'select',
199
+ value: selected,
200
+ disabled,
201
+ onChange: (event) => {
202
+ setSelected(event.target.value)
203
+ if (phase === 'saved' || phase === 'error') setPhase('ready')
204
+ },
205
+ style: {
206
+ minWidth: 280,
207
+ maxWidth: '100%',
208
+ height: 36,
209
+ padding: '0 10px',
210
+ borderRadius: 8,
211
+ border: '0.5px solid var(--dsw-alias-border-l4)',
212
+ background: 'var(--dsw-alias-bg-layer-3)',
213
+ color: 'var(--dsw-alias-label-primary)',
214
+ font: 'inherit',
215
+ fontSize: 13,
216
+ outline: 'none',
217
+ },
218
+ }, options),
219
+ h('button', {
220
+ key: 'refresh',
221
+ type: 'button',
222
+ disabled: phase === 'loading' || phase === 'saving',
223
+ onClick: load,
224
+ style: {
225
+ height: 36,
226
+ padding: '0 14px',
227
+ borderRadius: 8,
228
+ border: '0.5px solid var(--dsw-alias-border-l4)',
229
+ background: 'var(--dsw-alias-bg-layer-3)',
230
+ color: 'var(--dsw-alias-label-primary)',
231
+ font: 'inherit',
232
+ fontSize: 13,
233
+ cursor: 'pointer',
234
+ },
235
+ }, t('refresh')),
236
+ h('button', {
237
+ key: 'save',
238
+ type: 'button',
239
+ disabled,
240
+ onClick: save,
241
+ style: {
242
+ height: 36,
243
+ padding: '0 16px',
244
+ borderRadius: 8,
245
+ border: 'none',
246
+ background: 'var(--dsw-alias-state-business-primary, #4d6bfe)',
247
+ color: '#fff',
248
+ font: 'inherit',
249
+ fontSize: 13,
250
+ cursor: disabled ? 'default' : 'pointer',
251
+ opacity: disabled ? 0.6 : 1,
252
+ },
253
+ }, phase === 'saving' ? t('saving') : t('save')),
254
+ ]),
255
+ h('p', {
256
+ key: 'status',
257
+ style: { margin: 0, fontSize: 12, lineHeight: '18px', color: statusColor },
258
+ }, statusText),
259
+ ])
260
+ }
261
+
262
+ function TargetSessionIcon(props) {
263
+ const size = Number(props && props.size) || 16
264
+ return h('svg', {
265
+ viewBox: '0 0 16 16',
266
+ width: size,
267
+ height: size,
268
+ fill: 'none',
269
+ stroke: 'currentColor',
270
+ strokeWidth: 1.5,
271
+ strokeLinecap: 'round',
272
+ strokeLinejoin: 'round',
273
+ 'aria-hidden': 'true',
274
+ 'data-dsh-panel-entry': PANEL_ID,
275
+ }, [
276
+ h('path', {
277
+ key: 'bubble',
278
+ d: 'M2.5 2.75h9a1.5 1.5 0 0 1 1.5 1.5v3.5a1.5 1.5 0 0 1-1.5 1.5H6.75L4 12.25V9.25H2.5a1.5 1.5 0 0 1-1.5-1.5v-3.5a1.5 1.5 0 0 1 1.5-1.5Z',
279
+ }),
280
+ h('path', { key: 'line1', d: 'M4.25 5.5h5.5' }),
281
+ h('path', { key: 'line2', d: 'M4.25 7.75h3.25' }),
282
+ ])
283
+ }
284
+
285
+ function TargetSessionPage(props) {
286
+ const t = props.t
287
+ return h('div', {
288
+ style: {
289
+ height: '100%',
290
+ boxSizing: 'border-box',
291
+ overflow: 'auto',
292
+ padding: '28px 36px 48px',
293
+ color: 'var(--dsw-alias-label-primary)',
294
+ },
295
+ }, [
296
+ h('h1', {
297
+ key: 'title',
298
+ style: { margin: '0 0 18px', fontSize: 20, fontWeight: 600, lineHeight: '28px' },
299
+ }, t('panelTitle')),
300
+ h(TargetSessionCard, { key: 'card', ...props, view: 'page' }),
301
+ ])
302
+ }
303
+
304
+ function apply(ctx) {
305
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'qq-tools: dictionaries')
306
+ const t = ctx.locale.bind(NS)
307
+ ctx.effect(() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
308
+ name: 'sidebar.panellist',
309
+ id: PANEL_ID,
310
+ order: PANEL_ORDER,
311
+ label: () => t('sidebarLabel'),
312
+ }, TargetSessionIcon)), 'qq-tools: sidebar entry')
313
+ ctx.effect(() => ctx.slots.inject('main', () => ctx.slots.register({
314
+ name: 'main',
315
+ key: PANEL_ID,
316
+ }, (props) => h(TargetSessionPage, { ...props, t }))), 'qq-tools: main panel')
317
+ ctx.effect(() => ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
318
+ name: 'plugins.row.config',
319
+ key: SLOT_KEY,
320
+ locale: NS,
321
+ }, TargetSessionCard)), 'qq-tools: target session page')
322
+ }
323
+
324
+ exports.apply = apply
325
+ exports.inject = inject
326
+ return module.exports
327
+ },
328
+ })
package/lib/index.js CHANGED
@@ -1,15 +1,21 @@
1
- // dsh-qq-tools —— 整体插件入口(兼容用)
1
+ // dsh-qq-tools —— 包根入口。
2
2
  //
3
- // 正常的 bundle 入口现在是 cordis.patch.yml 里的 8 个组件行,每个工具一个组件、
4
- // 各自有开关。这个文件保留成「一个插件挂全部工具」的老入口,方便直接引用
5
- // `dsh-qq-tools` 的旧配置;DSH 插件页不会用它。
6
- import { mountAll } from './shared.js'
3
+ // 两用:
4
+ // · cordis.patch.yml 里的包根行用 config.carrier = true 启动它,只承载
5
+ // 「挂载会话」的 HTTP 接口;浏览器半侧(lib/client.js)也只能从包根行
6
+ // 被发现,子路径行不会被 dsh-client-modules 扫描到。
7
+ // · 旧配置直接引用 `dsh-qq-tools` 时没有 carrier 标记,仍然挂全部工具。
8
+ import { mountAll, mountTargetSessionRoute } from './shared.js'
7
9
 
8
10
  export * from './shared.js'
9
11
 
10
12
  export const name = 'qq-tools'
11
- export const inject = ['agents', 'timer', 'tools', 'attachments']
13
+ export const inject = ['agents', 'timer', 'tools', 'attachments', 'webServer']
12
14
 
13
- export function apply(ctx) {
15
+ export function apply(ctx, config) {
16
+ if (config && config.carrier === true) {
17
+ mountTargetSessionRoute(ctx)
18
+ return {}
19
+ }
14
20
  return mountAll(ctx)
15
21
  }
package/lib/shared.js CHANGED
@@ -34,7 +34,7 @@
34
34
  //
35
35
  // 工具本身的开关在 DSH 插件页的组件列表里:关掉某个组件,它就不再注册对应工具。
36
36
 
37
- import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs'
37
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs'
38
38
  import { join } from 'node:path'
39
39
  import { homedir } from 'node:os'
40
40
  import { randomUUID } from 'node:crypto'
@@ -47,21 +47,36 @@ const DIAG_PATH = join(DSH_HOME, 'qq-tools-diag.log')
47
47
 
48
48
 
49
49
  const requireFromHere = createRequire(import.meta.url)
50
- let sharp = null
51
- for (const p of [
52
- process.env.SHARP_PATH,
53
- '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
54
- '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
55
- ]) {
56
- if (!p) continue
57
- try {
58
- if (existsSync(p)) {
59
- sharp = requireFromHere(p)
60
- break
61
- }
62
- } catch (error) {}
50
+
51
+ // sharp 是可选的,按这个顺序找:SHARP_PATH → 插件自己装的 → 宿主进程能解析到的。
52
+ // DSH 桌面版把 sharp 打在 resources/app.asar/dsh/node_modules 下,CLI 版跟着
53
+ // @deepseek-ai/dsh 的依赖走,所以从宿主的启动入口解析最稳,不用写死安装路径。
54
+ function loadSharp() {
55
+ const probes = []
56
+ if (process.env.SHARP_PATH) probes.push(() => requireFromHere(process.env.SHARP_PATH))
57
+ probes.push(() => requireFromHere('sharp'))
58
+ if (process.argv[1]) probes.push(() => createRequire(process.argv[1])('sharp'))
59
+ if (process.resourcesPath) {
60
+ probes.push(() => createRequire(join(process.resourcesPath, 'app.asar', 'dsh', 'package.json'))('sharp'))
61
+ }
62
+ for (const p of [
63
+ '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
64
+ '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
65
+ ]) {
66
+ probes.push(() => requireFromHere(p))
67
+ }
68
+ for (const probe of probes) {
69
+ try {
70
+ const s = probe()
71
+ if (s) return s
72
+ } catch (error) {}
73
+ }
74
+ return null
63
75
  }
64
76
 
77
+ const sharp = loadSharp()
78
+ let gifWarned = false
79
+
65
80
  const DEFAULT_GROUPS = []
66
81
  const DEFAULT_CFG = {
67
82
  napCat: 'http://127.0.0.1:3000',
@@ -122,6 +137,28 @@ function loadCfg() {
122
137
  }
123
138
  }
124
139
 
140
+ // UI 保存挂载会话时只覆盖传进来的字段,其余配置原样保留;先写临时文件再换名,
141
+ // 避免写一半留下坏 JSON 让后面所有工具都退回默认配置。
142
+ export function saveCfg(patch) {
143
+ ensureCfg()
144
+ let current = {}
145
+ try {
146
+ const raw = JSON.parse(readFileSync(CFG_PATH, 'utf8'))
147
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) current = raw
148
+ } catch (error) {}
149
+ const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}) }
150
+ try {
151
+ mkdirSync(DSH_HOME, { recursive: true })
152
+ const tmp = CFG_PATH + '.tmp'
153
+ writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8')
154
+ renameSync(tmp, CFG_PATH)
155
+ } catch (error) {
156
+ diag('保存配置失败:' + String((error && error.message) || error))
157
+ throw error
158
+ }
159
+ return next
160
+ }
161
+
125
162
  function targetSessionId() {
126
163
  const cfg = loadCfg()
127
164
  return String(cfg.targetSession || '')
@@ -157,6 +194,103 @@ function findAgent(agents, id) {
157
194
  return undefined
158
195
  }
159
196
 
197
+
198
+ // ---------- 目标会话 UI 接口(由「挂载会话」组件启动) ----------
199
+ // 浏览器半侧只读写 targetSession:GET 返回当前值和活着的会话,POST 写回配置文件;
200
+ // 其他组件仍然每 3 秒 loadCfg(),所以保存后不需要重启。
201
+ const TARGET_SESSION_ROUTE = '/api/dsh-qq-tools/target-session'
202
+
203
+ function replyJson(res, status, body) {
204
+ const text = JSON.stringify(body)
205
+ try {
206
+ res.writeHead(status, {
207
+ 'content-type': 'application/json; charset=utf-8',
208
+ 'cache-control': 'no-store',
209
+ })
210
+ res.end(text)
211
+ } catch (error) {}
212
+ }
213
+
214
+ async function readRequestJson(req, max = 64 * 1024) {
215
+ const chunks = []
216
+ let size = 0
217
+ for await (const chunk of req) {
218
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
219
+ size += buf.length
220
+ if (size > max) throw new Error('body too large')
221
+ chunks.push(buf)
222
+ }
223
+ if (!chunks.length) return {}
224
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
225
+ }
226
+
227
+ function sameOriginRequest(req) {
228
+ const origin = String((req.headers && req.headers.origin) || '')
229
+ if (!origin) return true
230
+ try {
231
+ return new URL(origin).host === String((req.headers && req.headers.host) || '')
232
+ } catch (error) {
233
+ return false
234
+ }
235
+ }
236
+
237
+ export function mountTargetSessionRoute(ctx) {
238
+ const webServer = ctx.get('webServer')
239
+ if (!webServer || typeof webServer.register !== 'function') {
240
+ diag('挂载会话:宿主没有 webServer,设置页接口不可用')
241
+ return
242
+ }
243
+ ctx.effect(() => {
244
+ const dispose = webServer.register({
245
+ kind: 'exact',
246
+ path: TARGET_SESSION_ROUTE,
247
+ handler: async (req, res) => {
248
+ if (!sameOriginRequest(req)) {
249
+ replyJson(res, 403, { ok: false, error: 'forbidden' })
250
+ return
251
+ }
252
+ const method = String(req.method || 'GET').toUpperCase()
253
+ if (method === 'GET') {
254
+ const liveSessionIds = []
255
+ try {
256
+ const agents = ctx.get('agents') || ctx.agents
257
+ for (const agent of (agents && agents.list()) || []) {
258
+ const sid = sessionIdOf(agent)
259
+ if (sid) liveSessionIds.push(sid)
260
+ }
261
+ } catch (error) {}
262
+ replyJson(res, 200, { ok: true, targetSession: targetSessionId(), liveSessionIds })
263
+ return
264
+ }
265
+ if (method !== 'POST') {
266
+ replyJson(res, 405, { ok: false, error: 'method not allowed' })
267
+ return
268
+ }
269
+ let body
270
+ try {
271
+ body = await readRequestJson(req)
272
+ } catch (error) {
273
+ replyJson(res, 400, { ok: false, error: 'invalid body' })
274
+ return
275
+ }
276
+ const raw = body && typeof body.targetSession === 'string' ? body.targetSession : ''
277
+ const targetSession = clean(raw).slice(0, 200)
278
+ try {
279
+ saveCfg({ targetSession })
280
+ } catch (error) {
281
+ replyJson(res, 500, { ok: false, error: 'save failed' })
282
+ return
283
+ }
284
+ diag('目标会话已更新:' + (targetSession || '(不挂载)'))
285
+ replyJson(res, 200, { ok: true, targetSession })
286
+ },
287
+ })
288
+ return () => {
289
+ try { dispose() } catch (error) {}
290
+ }
291
+ }, 'qq-tools: target session route')
292
+ }
293
+
160
294
  function allowedGroup(groupId, cfg) {
161
295
  if (!Array.isArray(cfg.groups) || cfg.groups.length === 0) return true
162
296
  return cfg.groups.includes(Number(groupId))
@@ -220,6 +354,14 @@ function segText(segs) {
220
354
  .join(''))
221
355
  }
222
356
 
357
+ // mface(商城表情/表情包)基本都是动图;普通 image 段只能从名字和摘要里猜。
358
+ function animatedSeg(seg) {
359
+ if (!seg || !seg.data) return false
360
+ const d = seg.data
361
+ return seg.type === 'mface' ||
362
+ /gif|动画/i.test(String(d.summary || '') + ' ' + String(d.file || '') + ' ' + String(d.url || ''))
363
+ }
364
+
223
365
  function fmtTime(sec) {
224
366
  const t = Number(sec)
225
367
  if (!Number.isFinite(t) || t <= 0) return ''
@@ -427,10 +569,14 @@ export function admitImagesFor(ctx) {
427
569
  if (prepared.length >= 6) break
428
570
  continue
429
571
  }
572
+ if (!sharp && !gifWarned) {
573
+ gifWarned = true
574
+ diag('GIF 帧拆分不可用:没有加载到 sharp,动图会按静图处理')
575
+ }
430
576
  }
431
577
  prepared.push({
432
578
  caption: String(x.caption || ''),
433
- block: { type: 'image', data: bytes.toString('base64'), mediaType: mediaTypeOf(name), name },
579
+ block: { type: 'image', data: bytes.toString('base64'), mediaType: isGif ? 'image/gif' : mediaTypeOf(name), name },
434
580
  })
435
581
  if (prepared.length >= 6) break
436
582
  }
@@ -491,11 +637,7 @@ export function makeHistoryTool(admitImages) {
491
637
  const segs = Array.isArray(m.message) ? m.message : []
492
638
  for (const seg of segs) {
493
639
  if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
494
- const d = seg.data
495
- const animated =
496
- seg.type === 'mface' ||
497
- /gif|动画/i.test(String(d.summary || '') + ' ' + String(d.file || '') + ' ' + String(d.url || ''))
498
- picked.push({ m, seg, animated })
640
+ picked.push({ m, seg, animated: animatedSeg(seg) })
499
641
  }
500
642
  }
501
643
  const anim = picked.filter((x) => x.animated).slice(-2)
@@ -708,10 +850,7 @@ export function makePrivateHistoryTool(admitImages) {
708
850
  const segs = Array.isArray(m.message) ? m.message : []
709
851
  for (const seg of segs) {
710
852
  if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
711
- const animated =
712
- seg.type === 'mface' ||
713
- /gif|动画/i.test(String(seg.data.summary || '') + ' ' + String(seg.data.file || '') + ' ' + String(seg.data.url || ''))
714
- picked.push({ m, seg, animated })
853
+ picked.push({ m, seg, animated: animatedSeg(seg) })
715
854
  }
716
855
  }
717
856
  const anim = picked.filter((x) => x.animated).slice(-2)
@@ -1594,19 +1733,22 @@ async function handlePrivateMessage(ctx, ev, admitImages) {
1594
1733
  '不想回就安静待着;不要用 Markdown,不要提工具,短一点。'
1595
1734
  const content = [{ type: 'text', text: prompt }]
1596
1735
  if (typeof admitImages === 'function') {
1597
- const picked = []
1736
+ // 和群/私聊记录一样:先保证最近两张动图能进来,剩下的名额给静图。
1737
+ const anim = []
1738
+ const stat = []
1598
1739
  for (const seg of segs) {
1599
1740
  if (!seg || (seg.type !== 'image' && seg.type !== 'mface') || !seg.data) continue
1600
- picked.push(seg)
1601
- if (picked.length >= 4) break
1741
+ const item = { seg, animated: animatedSeg(seg) }
1742
+ if (item.animated) anim.push(item)
1743
+ else stat.push(item)
1602
1744
  }
1745
+ const animPick = anim.slice(-2)
1746
+ const picked = [...animPick, ...stat.slice(-(4 - animPick.length))]
1603
1747
  if (picked.length) {
1604
- const images = await admitImages(picked.map((seg, i) => ({
1748
+ const images = await admitImages(picked.map(({ seg, animated }, i) => ({
1605
1749
  data: seg.data,
1606
1750
  cfg,
1607
- animated:
1608
- seg.type === 'mface' ||
1609
- /gif|动画/i.test(String(seg.data.summary || '') + ' ' + String(seg.data.file || '') + ' ' + String(seg.data.url || '')),
1751
+ animated,
1610
1752
  name: (seg.data && seg.data.file) || `qq-private-${ev.message_id || 'x'}-${i}.jpg`,
1611
1753
  caption: `私聊 ${name} 发的图片(消息 ${ev.message_id || '?'})`,
1612
1754
  })))
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "dsh-qq-tools",
4
+ "description": "QQ group/private chat tools for DSH: list groups, read history, send messages and faces, manage SnowLuma favorites, private chat, pokes, and target-session selection."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "dsh-qq-tools",
4
+ "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群聊天记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-qq-tools",
3
- "version": "0.7.0",
4
- "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去。8 个工具各是一个独立组件,可在插件页逐个开关。",
3
+ "version": "0.8.0",
4
+ "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去、挂载会话选择。8 个工具加 1 个设置组件,可在插件页逐个开关。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "lib",
28
28
  "tools",
29
+ "locale",
29
30
  "cordis.patch.yml",
30
31
  "README.md",
31
32
  "LICENSE"
@@ -35,6 +36,7 @@
35
36
  },
36
37
  "exports": {
37
38
  ".": "./lib/index.js",
39
+ "./client": "./lib/client.js",
38
40
  "./tools/qq-list-groups": "./tools/qq-list-groups/index.js",
39
41
  "./tools/qq-list-groups/locale/en.json": "./tools/qq-list-groups/locale/en.json",
40
42
  "./tools/qq-list-groups/locale/zh.json": "./tools/qq-list-groups/locale/zh.json",
@@ -59,12 +61,22 @@
59
61
  "./tools/qq-send-poke": "./tools/qq-send-poke/index.js",
60
62
  "./tools/qq-send-poke/locale/en.json": "./tools/qq-send-poke/locale/en.json",
61
63
  "./tools/qq-send-poke/locale/zh.json": "./tools/qq-send-poke/locale/zh.json",
64
+ "./locale/en.json": "./locale/en.json",
65
+ "./locale/zh.json": "./locale/zh.json",
62
66
  "./package.json": "./package.json"
63
67
  },
64
- "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去。8 个工具各是一个独立组件,可在插件页逐个开关。",
65
68
  "dsh": {
66
69
  "bundle": {
67
70
  "patch": "./cordis.patch.yml"
71
+ },
72
+ "client": {
73
+ "inject": [
74
+ "@deepseek-ai/dsh-client-locale",
75
+ "@deepseek-ai/dsh-client-ui-renderer",
76
+ "@deepseek-ai/dsh-client-ui-settings",
77
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
78
+ ],
79
+ "platform": "web"
68
80
  }
69
81
  }
70
82
  }