dsh-qq-tools 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cyhddyx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ # dsh-qq-tools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 DSH(DeepSeek Harness)里的大肥鱼会话用的 QQ 群/私聊工具,走 OneBot 11 HTTP + WebSocket。系统表情在 SnowLuma / NapCat 上都能发;收藏表情用 SnowLuma 的自定义表情扩展接口,和 QQ 客户端里的收藏夹是同一份数据。
4
+
5
+ 8 个工具各自是一个独立的 cordis 组件,外加一个负责选择挂载会话的设置组件,在 DSH 插件页的「包含的组件」里可以逐个开关:
6
+
7
+ | 组件 | 工具 | 说明 |
8
+ | --- | --- | --- |
9
+ | 列出 QQ 群 | `qq_list_groups` | 群号、群名、人数 |
10
+ | 看群聊天记录 | `qq_get_group_history` | 最近消息 + 图片,只读;消息带 `#message_id` |
11
+ | 看群成员 | `qq_get_group_members` | 昵称、群名片、QQ 号、身份 |
12
+ | 发群消息 | `qq_send_group_message` | 发群消息;同时负责 @ 提醒和群后续轮询 |
13
+ | 发表情 | `qq_send_face` | 在群里或私聊里发系统表情或 QQ 收藏夹里的自定义表情,可带一句话 |
14
+ | 收藏表情 | `qq_list_faces` / `qq_favorite_face` | 看/管理 QQ 账号收藏夹(SnowLuma):收藏图片、取消收藏、改备注、移到最前 |
15
+ | 私聊 | `qq_list_friends` / `qq_get_private_history` / `qq_send_private_message` | 好友列表、私聊记录、发私聊消息;同时负责私聊消息唤醒 |
16
+ | 戳一戳 | `qq_send_poke` | 戳人;同时负责接收戳一戳事件 |
17
+ | 挂载会话 | — | 插件页里的「配置」页面:下拉选择 QQ 消息唤醒哪个 DSH 会话 |
18
+
19
+ 关掉「发群消息」会同时停掉 @ 提醒轮询,关掉「私聊」会同时停掉私聊消息唤醒,关掉「戳一戳」会同时停掉戳一戳通知;三个只读工具互不影响。关掉「挂载会话」只会让配置页面不可用,已经写进配置的 `targetSession` 仍然生效。
20
+
21
+ ## 目录结构
22
+
23
+ ```
24
+ cordis.patch.yml 9 个组件行
25
+ lib/shared.js 共享核心:配置、OneBot、挂载、@ 轮询、私聊唤醒、戳一戳、表情、观察窗
26
+ lib/client.js 浏览器半侧:插件页里的挂载会话下拉框
27
+ lib/index.js 包根 carrier(承载浏览器半侧和挂载会话接口),也是旧的全量入口
28
+ locale/ 包根行在插件页显示的标题和说明
29
+ tools/qq-list-groups/ 组件入口 + locale
30
+ tools/qq-get-group-history/
31
+ tools/qq-get-group-members/
32
+ tools/qq-send-group-message/
33
+ tools/qq-send-face/
34
+ tools/qq-favorite-faces/
35
+ tools/qq-private-chat/
36
+ tools/qq-send-poke/
37
+ ```
38
+
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` 仍然生效,也可以直接改配置文件。
52
+
53
+ ## 配置
54
+
55
+ 首次启动会自动创建 `~/.dsh/qq-tools-config.json`:
56
+
57
+ ```json
58
+ {
59
+ "napCat": "http://127.0.0.1:3000",
60
+ "accessToken": "",
61
+ "targetSession": "",
62
+ "groups": [],
63
+ "historyCount": 30,
64
+ "maxHistoryCount": 50,
65
+ "pollMs": 5000,
66
+ "mentionEnabled": true,
67
+ "wsUrl": "ws://127.0.0.1:3001",
68
+ "pokeEnabled": true,
69
+ "privateEnabled": true,
70
+ "privateUsers": [],
71
+ "observationMs": 60000,
72
+ "timeoutMs": 6000
73
+ }
74
+ ```
75
+
76
+ - `napCat` 是 OneBot 11 HTTP 地址(SnowLuma 默认 `http://127.0.0.1:3000`,NapCat 也一样);`wsUrl` 是 WebSocket 事件地址(默认 `ws://127.0.0.1:3001`)。
77
+ - `targetSession` 是目标会话 ID;可以在插件页「挂载会话 → 配置」里下拉选择,也可以直接改这个文件;留空时工具不会挂到任何会话。
78
+ - `groups` 是允许看/发的群;空数组表示不限制。
79
+ - `mentionEnabled` / `pokeEnabled` / `privateEnabled` 分别是 @ 轮询、戳一戳事件、私聊唤醒的总闸,对应组件关掉时它们也不会运行。
80
+ - `privateUsers` 是允许唤醒和私聊工具的 QQ 白名单;空数组表示不限制。非空时,不在列表里的私聊消息不会唤醒,`qq_list_friends` 也不会列出他们。
81
+
82
+ ## 表情
83
+
84
+ 发表情和收藏表情都走 SnowLuma 的扩展接口,收藏夹和 QQ 客户端里的是同一份数据。
85
+
86
+ - `qq_list_faces`:列 QQ 收藏夹(`emoji_id` + 备注),默认给 3 张不同位置的预览图;想看指定表情就传 `preview`(列表序号或 `emoji_id`,最多 6 张)。传 `query` 时用 SnowLuma 的 `search_sys_faces` 按名字/别名/编号搜系统表情,搜不到再退回内置的常用表情表。
87
+ - `qq_send_face`:`face_id` 发系统表情(OneBot 11 的 `face` 段,SnowLuma 会自动选 classic / small / super 编码);`emoji_id` 发收藏表情(`send_custom_face`)。两者二选一;带 `text` 时系统表情和文字同一条发出,收藏表情则先发文字再发图。群聊传 `group_id`,私聊传 `user_id`(可选 `group_id` 表示临时会话来源群)。
88
+ - `qq_favorite_face`:
89
+ - `file` 传 `http(s)` 链接、`base64://`、本机路径或 `data:image/...`,收藏一张新表情;
90
+ - `message_id` + `index` 直接从聊天消息里的图片收藏(群记录里的 `[图片#0]` / `[表情包#0]` 就是索引);
91
+ - `emoji_id` + `remove` 取消收藏;
92
+ - `emoji_id` + `desc` 改备注;
93
+ - `emoji_id` + `move_to_front` 移到收藏夹最前。
94
+ - 聊天记录里的 `#编号` 是 `message_id`,可以用 `qq_send_face` 的 `reply_to` 回复,也可以配合 `qq_favorite_face` 收藏消息里的图片。
95
+
96
+ 这些自定义表情接口是 SnowLuma 扩展,NapCat 没有。用 NapCat 时系统表情照常能发,收藏表情工具会提示接口不可用。
97
+
98
+ ## 私聊
99
+
100
+ 私聊和群聊在程序里是分开的两条路径:群聊靠轮询 @,私聊靠 WebSocket 的 `message_type=private` 事件。私聊没有观察窗,每收到一条私聊消息都会直接唤醒目标会话。
101
+
102
+ - 唤醒提示里会带发送者昵称、QQ 号、`message_id` 和文字;如果消息里有图片,也会把图片一起给模型看(GIF 会拆成首/中/尾最多 3 帧,和群聊记录的处理一致)。
103
+ - 模型用 `qq_get_private_history` 看和对方的最近记录,用 `qq_send_private_message` 回文字,用 `qq_send_face` 回表情。
104
+ - `qq_list_friends` 列好友,可以按昵称、备注或 QQ 号搜;`privateUsers` 非空时只列白名单里的好友。
105
+ - `qq_get_private_history` 的消息带 `#message_id`,可以传给 `qq_send_private_message` / `qq_send_face` 的 `reply_to` 做引用回复。
106
+ - 私聊唤醒的 `source.kind` 是 `qq-private`,和群聊的 `qq-mention` / `qq-followup` / `qq-poke` 区分开。
107
+
108
+ ## 安装
109
+
110
+ DSH 插件用 `dsh plugin` 安装。`<profile>` 换成你启动 DSH 用的 profile 名(桌面端通常是 `desktop`,Web 端常见 `web`,也可以是 `tui` 或你自己的 profile)。
111
+
112
+ ### 从 GitHub 安装(现在就能用)
113
+
114
+ ```sh
115
+ dsh plugin --profile <profile> add github:cyhddyx/dsh-qq-tools
116
+ ```
117
+
118
+ 本插件是纯 JavaScript,没有构建步骤,所以不需要 `prepare` 脚本,也不需要给 pnpm 加 `allowBuilds` 授权。安装完成后重启 DSH,插件页里应该能看到 8 个组件,每个带开关。
119
+
120
+ ### 从 npm 安装(推荐)
121
+
122
+ 已发布到 npm,装最新版:
123
+
124
+ ```sh
125
+ dsh plugin --profile <profile> add dsh-qq-tools
126
+ ```
127
+
128
+ 需要固定版本时:
129
+
130
+ ```sh
131
+ dsh plugin --profile <profile> add dsh-qq-tools@0.8.0
132
+ ```
133
+
134
+ ### 本地开发 / 自己改代码
135
+
136
+ ```sh
137
+ dsh plugin --profile <profile> add link:/path/to/dsh-qq-tools
138
+ ```
139
+
140
+ ### 离线 tarball
141
+
142
+ ```sh
143
+ pnpm pack
144
+ dsh plugin --profile <profile> add ./dsh-qq-tools-0.8.0.tgz
145
+ ```
146
+
147
+ `dsh plugin` 会在安装后自动把这个包追加进 profile 的 `dsh.profile.bundles`,不用手改 `package.json`。卸载用:
148
+
149
+ ```sh
150
+ dsh plugin --profile <profile> remove dsh-qq-tools
151
+ ```
152
+
153
+ ## 依赖
154
+
155
+ - DSH 0.2.x(cordis 插件体系)
156
+ - Node.js 22+(内置 `fetch` / `WebSocket`)
157
+ - SnowLuma 提供 OneBot 11 HTTP 接口和 WebSocket 事件(收藏表情需要 SnowLuma;系统表情 NapCat 也能发)
158
+ - 可选:`sharp`(GIF 帧拆分)。会自动使用插件自己装的、`SHARP_PATH` 指定的,或 DSH 宿主自带的 sharp;都找不到时 GIF 按静图处理
159
+
160
+ ---
161
+
162
+ 版本:0.8.0
@@ -0,0 +1,30 @@
1
+ # DSH 大肥鱼 QQ 群/私聊工具:9 个组件,插件页里可以逐个开关
2
+ # · 列出 QQ 群 / 看群聊天记录 / 看群成员 —— 只读工具
3
+ # · 发群消息 —— 同时负责 @ 提醒与群后续轮询
4
+ # · 发表情 —— 在群里或私聊里发系统表情或 QQ 收藏夹里的自定义表情
5
+ # · 收藏表情 —— QQ 收藏夹(SnowLuma):qq_list_faces + qq_favorite_face
6
+ # · 私聊 —— 好友列表 / 私聊记录 / 发私聊消息,同时负责私聊消息唤醒
7
+ # · 戳一戳 —— 同时负责接收戳一戳事件
8
+ # · 挂载会话 —— 插件页里的「配置」页面,选择 QQ 消息唤醒哪个 DSH 会话
9
+ - insert:
10
+ - id: qq-tools-list-groups
11
+ name: 'dsh-qq-tools/tools/qq-list-groups'
12
+ - id: qq-tools-group-history
13
+ name: 'dsh-qq-tools/tools/qq-get-group-history'
14
+ - id: qq-tools-group-members
15
+ name: 'dsh-qq-tools/tools/qq-get-group-members'
16
+ - id: qq-tools-send-group-message
17
+ name: 'dsh-qq-tools/tools/qq-send-group-message'
18
+ - id: qq-tools-send-face
19
+ name: 'dsh-qq-tools/tools/qq-send-face'
20
+ - id: qq-tools-favorite-faces
21
+ name: 'dsh-qq-tools/tools/qq-favorite-faces'
22
+ - id: qq-tools-private-chat
23
+ name: 'dsh-qq-tools/tools/qq-private-chat'
24
+ - id: qq-tools-send-poke
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 ADDED
@@ -0,0 +1,21 @@
1
+ // dsh-qq-tools —— 包根入口。
2
+ //
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'
9
+
10
+ export * from './shared.js'
11
+
12
+ export const name = 'qq-tools'
13
+ export const inject = ['agents', 'timer', 'tools', 'attachments', 'webServer']
14
+
15
+ export function apply(ctx, config) {
16
+ if (config && config.carrier === true) {
17
+ mountTargetSessionRoute(ctx)
18
+ return {}
19
+ }
20
+ return mountAll(ctx)
21
+ }