dsh-qq-tools 0.10.1 → 0.10.2

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/lib/prompts.js ADDED
@@ -0,0 +1,56 @@
1
+ // 唤醒提示词与回复风格。只放文案和拼接,不碰网络和状态。
2
+
3
+ import { clean } from './utils.js'
4
+ import { hasActiveTool } from './session.js'
5
+
6
+ // 唤醒提示里的消息行:和戳一戳一致,固定带上昵称和 QQ 号。
7
+ // 有 message_id 时附在末尾,模型可以把它传给 reply_to 做引用。
8
+ export function formatIncomingLine(name, userId, text, messageId) {
9
+ const who = clean(name) || 'QQ'
10
+ const qq = String(userId || '?')
11
+ return `${who}(${qq}):${text || '(空)'}` + (messageId != null ? `(消息 #${messageId})` : '')
12
+ }
13
+
14
+ // 回复风格:群聊要像随手打字,短、分条、不刷屏地 @ 和引用。
15
+ export const CHAT_STYLE = '回复像群友随手打字:想连发几条就**多次调用同一个工具**(一次调用只发一条),每条一句短话(十几二十个字),最多三次调用;'
16
+ + '不要句号收尾,不要写长段落,不要 Markdown,不要把昵称或 QQ 号写进正文,不要每句都 @ 或引用——'
17
+ + '只有确实要指代某条消息时才传 reply_to,只有必须提醒某个人时才传 at_user_ids。'
18
+
19
+ // 主动发表情:平时列表只给备注,真要发的时候才 preview 一张图,省 token。
20
+ export const FACE_STYLE = '想回表情时,约有 25% 的概率主动发一个收藏表情:'
21
+ + '先用 qq_list_faces 按备注挑一个,再用 qq_send_face 传 preview=true 看一眼这张图,确认合适再正式发;'
22
+
23
+ // 收到带图消息时给一个低概率的收藏引导;index 和 qq_favorite_face 的取图索引一致。
24
+ // desc 直接描述画面内容,否则 qq_list_faces 只有备注,后面根本不知道用哪张。
25
+ export function favoriteHint(text, messageId) {
26
+ const m = String(text || '').match(/\[(?:图片|表情包)#(\d+)\]/)
27
+ if (!m || messageId == null || messageId === '') return ''
28
+ return '这条消息里有图片,约有 25% 的概率可以顺手收藏一个你觉得好玩的'
29
+ + '(qq_favorite_face(message_id=' + String(messageId) + ', index=' + m[1] + ', desc=表情内容));'
30
+ + 'desc 直接简短描述画面内容,比如「闭眼安详」「锅铲敲地」「穿冬装的肥鱼」。'
31
+ }
32
+
33
+ const CHAT_BUBBLE_CHARS = 80
34
+
35
+ // 一条消息的清理:压掉复制来的对齐空格和多行,去掉正文开头重复的 @昵称。
36
+ function cleanBubble(value) {
37
+ return String(value || '')
38
+ .replace(/\r/g, '')
39
+ .replace(/[\u3000\t ]+/g, ' ')
40
+ .replace(/\s*\n\s*/g, ' ')
41
+ .trim()
42
+ .replace(/^@[^\s@]{1,40}\s+/, '')
43
+ .slice(0, CHAT_BUBBLE_CHARS)
44
+ }
45
+
46
+ // 一次工具调用只发一条:想分几条说就让模型多次调用工具。
47
+ export function chatText(args) {
48
+ return cleanBubble(args && args.message)
49
+ }
50
+
51
+ export function lookHint() {
52
+ const parts = []
53
+ if (hasActiveTool('qq_list_groups')) parts.push('先用 qq_list_groups 看看群')
54
+ if (hasActiveTool('qq_get_group_history')) parts.push('再用 qq_get_group_history 看 2~3 个群最近 30 条')
55
+ return parts.length ? '你可以' + parts.join(',') + ',' : ''
56
+ }
@@ -0,0 +1,51 @@
1
+ // QQ 运行时状态:登录号缓存 + 群名缓存。
2
+ import { onebot } from './onebot.js'
3
+ import { clean } from './utils.js'
4
+
5
+ // 这两份数据以前混在 @ 轮询里,被私聊/戳一戳/私聊记录共用;拆出来避免模块之间互相 import。
6
+ let selfId = ''
7
+ let names = new Map()
8
+ let namesAt = 0
9
+
10
+ // 当前登录的 QQ 号。第一次用到时向 SnowLuma 查一次,之后复用。
11
+ export async function getSelfId(cfg) {
12
+ if (selfId) return selfId
13
+ try {
14
+ const info = await onebot('get_login_info', {}, cfg)
15
+ selfId = String((info && info.user_id) || '')
16
+ } catch (error) {}
17
+ return selfId
18
+ }
19
+
20
+ // 只读缓存,不发请求;戳一戳事件本身带 self_id,缺省时保持原来的行为(不额外查登录号)。
21
+ export function cachedSelfId() {
22
+ return selfId
23
+ }
24
+
25
+ // 群名缓存拿不到时退回群号,和原来的 `names.get(gid) || gid` 一致。
26
+ export function groupName(groupId) {
27
+ const id = String(groupId)
28
+ return names.get(id) || id
29
+ }
30
+
31
+ async function refreshNames(cfg) {
32
+ if (names.size && Date.now() - namesAt < 300000) return
33
+ try {
34
+ const data = await onebot('get_group_list', {}, cfg)
35
+ const next = new Map()
36
+ for (const g of Array.isArray(data) ? data : []) next.set(String(g.group_id), clean(g.group_name || g.group_id))
37
+ if (next.size) {
38
+ names = next
39
+ namesAt = Date.now()
40
+ }
41
+ } catch (error) {}
42
+ }
43
+
44
+ export { refreshNames }
45
+
46
+ // 组件重新加载时从头开始,避免把停用期间的旧状态带进新实例。
47
+ export function resetQqState() {
48
+ selfId = ''
49
+ names = new Map()
50
+ namesAt = 0
51
+ }
package/lib/render.js ADDED
@@ -0,0 +1,49 @@
1
+ // 工具结果的文本渲染,以及常见的列表取值。
2
+
3
+ export function renderTool(_args, v) {
4
+ if (!v || !v.ok) return [{ type: 'text', text: 'QQ 工具失败:' + String((v && v.error) || '未知错误') }]
5
+ if (Array.isArray(v.groups)) {
6
+ if (!v.groups.length) return [{ type: 'text', text: '现在没有允许查看的群。' }]
7
+ return [{ type: 'text', text: '可以去的群:\n' + v.groups.map((g) => `- ${g.name || g.id}(群号 ${g.id},${g.member_count} 人)`).join('\n') }]
8
+ }
9
+ if (Array.isArray(v.friends)) {
10
+ if (!v.friends.length) return [{ type: 'text', text: v.query ? '没有匹配的好友。' : '好友列表是空的。' }]
11
+ const lines = v.friends.map((f) => `- ${f.remark || f.nickname || f.user_id}(${f.nickname || ''},QQ ${f.user_id})`)
12
+ return [{ type: 'text', text: `你的好友(${v.total} 个):\n` + lines.join('\n') }]
13
+ }
14
+ if (Array.isArray(v.messages)) {
15
+ const where = v.group_id != null ? `群 ${v.group_id}` : `QQ ${v.user_id}`
16
+ if (!v.messages.length) return [{ type: 'text', text: `${where} 最近没有消息。` }]
17
+ const lines = v.messages.map((m) => `${m.t || ''} [#${m.message_id || '?'}] ${m.sender}(${m.user_id}):${m.text || '(空)'}`)
18
+ const blocks = [{ type: 'text', text: `${where} 最近 ${v.count} 条:\n` + lines.join('\n') }]
19
+ if (Array.isArray(v.images) && v.images.length) {
20
+ blocks.push({ type: 'text', text: '下面是这些图片(按消息顺序):' })
21
+ for (const img of v.images) {
22
+ if (img && img.caption) blocks.push({ type: 'text', text: img.caption })
23
+ if (img && img.block) blocks.push(img.block)
24
+ }
25
+ }
26
+ return blocks
27
+ }
28
+ if (Array.isArray(v.members)) {
29
+ const lines = v.members.map((m) => `- ${m.card || m.nickname || m.user_id}(QQ ${m.user_id},${m.role || 'member'})`)
30
+ return [{ type: 'text', text: `群 ${v.group_id} 成员:\n` + (lines.join('\n') || '(空)') }]
31
+ }
32
+ if (v.poke) {
33
+ return [{ type: 'text', text: `戳回去了:${v.group_id ? '群 ' + v.group_id : '私聊'} QQ ${v.user_id}。` }]
34
+ }
35
+ if (v.group_id != null && v.message_id != null) {
36
+ return [{ type: 'text', text: `已发到群 ${v.group_id}(消息 ${v.message_id || '?'})。` }]
37
+ }
38
+ if (v.user_id != null && v.message_id != null) {
39
+ return [{ type: 'text', text: `已发给 QQ ${v.user_id}(消息 ${v.message_id || '?'})。` }]
40
+ }
41
+ return [{ type: 'text', text: JSON.stringify(v).slice(0, 2000) }]
42
+ }
43
+
44
+
45
+ export function listOf(data, key) {
46
+ if (Array.isArray(data)) return data
47
+ if (data && Array.isArray(data[key])) return data[key]
48
+ return []
49
+ }
package/lib/session.js ADDED
@@ -0,0 +1,201 @@
1
+ // 目标会话解析、工具挂载、挂载会话 HTTP 接口,以及当前活着的工具表。
2
+
3
+ import { clean } from './utils.js'
4
+ import { ensureCfg, loadCfg, saveCfg, diag } from './config.js'
5
+
6
+ export function targetSessionId() {
7
+ const cfg = loadCfg()
8
+ return String(cfg.targetSession || '')
9
+ }
10
+
11
+ function sessionIdOf(agent) {
12
+ try {
13
+ const s = agent && agent.session
14
+ return String((s && (s.id || s.sessionId)) || (agent && agent.id) || '')
15
+ } catch (error) {
16
+ return ''
17
+ }
18
+ }
19
+
20
+ export function findAgent(agents, id) {
21
+ const raw = String(id || '').trim()
22
+ if (!raw) return undefined
23
+ const candidates = [raw]
24
+ if (raw.startsWith('session-')) candidates.push(raw.slice('session-'.length))
25
+ else candidates.push('session-' + raw)
26
+ for (const one of candidates) {
27
+ try {
28
+ const a = agents.get(one)
29
+ if (a !== undefined) return a
30
+ } catch (error) {}
31
+ }
32
+ try {
33
+ for (const a of agents.list() || []) {
34
+ const sid = sessionIdOf(a)
35
+ if (sid === raw || 'session-' + sid === raw || sid === raw.replace(/^session-/, '')) return a
36
+ }
37
+ } catch (error) {}
38
+ return undefined
39
+ }
40
+
41
+
42
+ // ---------- 目标会话 UI 接口(由「挂载会话」组件启动) ----------
43
+ // 浏览器半侧只读写 targetSession:GET 返回当前值和活着的会话,POST 写回配置文件;
44
+ // 其他组件仍然每 3 秒 loadCfg(),所以保存后不需要重启。
45
+ const TARGET_SESSION_ROUTE = '/api/dsh-qq-tools/target-session'
46
+
47
+ function replyJson(res, status, body) {
48
+ const text = JSON.stringify(body)
49
+ try {
50
+ res.writeHead(status, {
51
+ 'content-type': 'application/json; charset=utf-8',
52
+ 'cache-control': 'no-store',
53
+ })
54
+ res.end(text)
55
+ } catch (error) {}
56
+ }
57
+
58
+ async function readRequestJson(req, max = 64 * 1024) {
59
+ const chunks = []
60
+ let size = 0
61
+ for await (const chunk of req) {
62
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
63
+ size += buf.length
64
+ if (size > max) throw new Error('body too large')
65
+ chunks.push(buf)
66
+ }
67
+ if (!chunks.length) return {}
68
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
69
+ }
70
+
71
+ function sameOriginRequest(req) {
72
+ const origin = String((req.headers && req.headers.origin) || '')
73
+ if (!origin) return true
74
+ try {
75
+ return new URL(origin).host === String((req.headers && req.headers.host) || '')
76
+ } catch (error) {
77
+ return false
78
+ }
79
+ }
80
+
81
+ export function mountTargetSessionRoute(ctx) {
82
+ const webServer = ctx.get('webServer')
83
+ if (!webServer || typeof webServer.register !== 'function') {
84
+ diag('挂载会话:宿主没有 webServer,设置页接口不可用')
85
+ return
86
+ }
87
+ ctx.effect(() => {
88
+ const dispose = webServer.register({
89
+ kind: 'exact',
90
+ path: TARGET_SESSION_ROUTE,
91
+ handler: async (req, res) => {
92
+ if (!sameOriginRequest(req)) {
93
+ replyJson(res, 403, { ok: false, error: 'forbidden' })
94
+ return
95
+ }
96
+ const method = String(req.method || 'GET').toUpperCase()
97
+ if (method === 'GET') {
98
+ const liveSessionIds = []
99
+ try {
100
+ const agents = ctx.get('agents') || ctx.agents
101
+ for (const agent of (agents && agents.list()) || []) {
102
+ const sid = sessionIdOf(agent)
103
+ if (sid) liveSessionIds.push(sid)
104
+ }
105
+ } catch (error) {}
106
+ replyJson(res, 200, { ok: true, targetSession: targetSessionId(), liveSessionIds })
107
+ return
108
+ }
109
+ if (method !== 'POST') {
110
+ replyJson(res, 405, { ok: false, error: 'method not allowed' })
111
+ return
112
+ }
113
+ let body
114
+ try {
115
+ body = await readRequestJson(req)
116
+ } catch (error) {
117
+ replyJson(res, 400, { ok: false, error: 'invalid body' })
118
+ return
119
+ }
120
+ const raw = body && typeof body.targetSession === 'string' ? body.targetSession : ''
121
+ const targetSession = clean(raw).slice(0, 200)
122
+ try {
123
+ saveCfg({ targetSession })
124
+ } catch (error) {
125
+ replyJson(res, 500, { ok: false, error: 'save failed' })
126
+ return
127
+ }
128
+ diag('目标会话已更新:' + (targetSession || '(不挂载)'))
129
+ replyJson(res, 200, { ok: true, targetSession })
130
+ },
131
+ })
132
+ return () => {
133
+ try { dispose() } catch (error) {}
134
+ }
135
+ }, 'qq-tools: target session route')
136
+ }
137
+
138
+ // ---------- 当前活着的工具组件 ----------
139
+ // @ 提醒的提示词只提还开着的工具,免得模型去调一个被关掉的组件。
140
+ const activeCounts = new Map()
141
+
142
+ function markActive(name, delta) {
143
+ const next = Math.max(0, (activeCounts.get(name) || 0) + delta)
144
+ if (next === 0) activeCounts.delete(name)
145
+ else activeCounts.set(name, next)
146
+ }
147
+
148
+ export function hasActiveTool(name) {
149
+ return (activeCounts.get(name) || 0) > 0
150
+ }
151
+
152
+
153
+ // ---------- 把一个工具挂到目标会话 ----------
154
+ // 每个工具组件各自调用一次:自己找目标会话、自己挂、自己卸,
155
+ // 所以关掉某个组件只会影响它自己的工具。
156
+ export function mountOneTool(ctx, tool) {
157
+ ensureCfg()
158
+ markActive(tool.name, 1)
159
+ const mounted = new Map()
160
+
161
+ function unmountAll(reason) {
162
+ for (const [sid, v] of mounted) {
163
+ try {
164
+ if (typeof v.off === 'function') v.off()
165
+ } catch (error) {}
166
+ diag('工具 ' + tool.name + ' 已从会话 ' + sid + ' 卸下:' + reason)
167
+ }
168
+ mounted.clear()
169
+ }
170
+
171
+ function tick() {
172
+ const target = targetSessionId()
173
+ if (!target) {
174
+ if (mounted.size) unmountAll('没有目标会话')
175
+ return
176
+ }
177
+ const agent = findAgent(ctx.agents, target)
178
+ if (!agent) {
179
+ if (mounted.size) unmountAll('目标会话没有活着')
180
+ return
181
+ }
182
+ const sid = sessionIdOf(agent)
183
+ if (mounted.has(sid)) return
184
+ unmountAll('目标会话变了')
185
+ try {
186
+ const off = agent.ctx.tools.register(tool)
187
+ mounted.set(sid, { agent, off })
188
+ diag('工具 ' + tool.name + ' 已挂到会话 ' + sid)
189
+ } catch (error) {
190
+ diag('工具挂载失败 ' + tool.name + ':' + String((error && error.message) || error))
191
+ }
192
+ }
193
+
194
+ tick()
195
+ ctx.effect(() => ctx.interval(tick, 3000))
196
+ ctx.effect(() => () => {
197
+ markActive(tool.name, -1)
198
+ unmountAll('插件卸载')
199
+ })
200
+ return { tool, tick, unmountAll, mounted }
201
+ }