dsh-qqchat 0.2.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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +276 -0
  3. package/README.md +211 -0
  4. package/client-src/plugin.cts +15 -0
  5. package/client-src/settings.cts +216 -0
  6. package/client-src/shared.cts +104 -0
  7. package/client-src/workspace.cts +164 -0
  8. package/cordis.patch.yml +7 -0
  9. package/docs/ARCHITECTURE.en.md +346 -0
  10. package/docs/ARCHITECTURE.md +411 -0
  11. package/docs/DEVELOPMENT.md +69 -0
  12. package/docs/MEDIA_AND_QUOTES.md +644 -0
  13. package/docs/MEMORY.md +99 -0
  14. package/docs/MEMORY_CONTEXT.md +254 -0
  15. package/docs/QUOTES.md +355 -0
  16. package/docs/README.md +22 -0
  17. package/docs/SECURITY.md +62 -0
  18. package/docs/STREAMING.md +347 -0
  19. package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
  20. package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
  21. package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
  22. package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
  23. package/docs/devlog.md +24 -0
  24. package/lib/client.js +433 -0
  25. package/lib/commands/dispatch.d.ts +16 -0
  26. package/lib/commands/dispatch.js +48 -0
  27. package/lib/config.d.ts +3 -0
  28. package/lib/config.js +63 -0
  29. package/lib/gateway/api.d.ts +30 -0
  30. package/lib/gateway/api.js +202 -0
  31. package/lib/gateway/auth.d.ts +35 -0
  32. package/lib/gateway/auth.js +80 -0
  33. package/lib/gateway/crypto.d.ts +2 -0
  34. package/lib/gateway/crypto.js +17 -0
  35. package/lib/gateway/gateway.d.ts +32 -0
  36. package/lib/gateway/gateway.js +219 -0
  37. package/lib/gateway/normalize.d.ts +6 -0
  38. package/lib/gateway/normalize.js +248 -0
  39. package/lib/index.d.ts +14 -0
  40. package/lib/index.js +35 -0
  41. package/lib/media/image-tool.d.ts +4 -0
  42. package/lib/media/image-tool.js +62 -0
  43. package/lib/media/media-tools.d.ts +3 -0
  44. package/lib/media/media-tools.js +68 -0
  45. package/lib/media/store.d.ts +14 -0
  46. package/lib/media/store.js +176 -0
  47. package/lib/session/agent-bridge.d.ts +56 -0
  48. package/lib/session/agent-bridge.js +613 -0
  49. package/lib/session/memory-snapshot.d.ts +23 -0
  50. package/lib/session/memory-snapshot.js +27 -0
  51. package/lib/session/runtime.d.ts +41 -0
  52. package/lib/session/runtime.js +395 -0
  53. package/lib/shared/augmentations.d.ts +40 -0
  54. package/lib/shared/augmentations.js +1 -0
  55. package/lib/shared/logging.d.ts +14 -0
  56. package/lib/shared/logging.js +41 -0
  57. package/lib/storage/db.d.ts +82 -0
  58. package/lib/storage/db.js +669 -0
  59. package/lib/storage/memory.d.ts +50 -0
  60. package/lib/storage/memory.js +566 -0
  61. package/lib/transport/rpc.d.ts +18 -0
  62. package/lib/transport/rpc.js +318 -0
  63. package/lib/types.d.ts +416 -0
  64. package/lib/types.js +1 -0
  65. package/package.json +115 -0
  66. package/scripts/wrap-client.mjs +20 -0
  67. package/src/commands/dispatch.ts +75 -0
  68. package/src/config.ts +70 -0
  69. package/src/gateway/api.ts +260 -0
  70. package/src/gateway/auth.ts +99 -0
  71. package/src/gateway/crypto.ts +16 -0
  72. package/src/gateway/gateway.ts +207 -0
  73. package/src/gateway/normalize.ts +236 -0
  74. package/src/index.ts +46 -0
  75. package/src/media/image-tool.ts +62 -0
  76. package/src/media/media-tools.ts +66 -0
  77. package/src/media/store.ts +157 -0
  78. package/src/session/agent-bridge.ts +624 -0
  79. package/src/session/memory-snapshot.ts +41 -0
  80. package/src/session/runtime.ts +368 -0
  81. package/src/shared/augmentations.ts +45 -0
  82. package/src/shared/logging.ts +36 -0
  83. package/src/storage/db.ts +760 -0
  84. package/src/storage/memory.ts +581 -0
  85. package/src/transport/rpc.ts +316 -0
  86. package/src/types.ts +456 -0
  87. package/tsconfig.build.json +12 -0
  88. package/tsconfig.client.json +14 -0
  89. package/tsconfig.json +23 -0
@@ -0,0 +1,236 @@
1
+ import type { QQAttachmentInput, QQAuthor, QQDispatchData, QQNormalizedMessage, QQQuoteInput, QQMediaKind } from '../types.js'
2
+
3
+ function authorId(author: QQAuthor = {}): string {
4
+ return String(author.user_openid || author.member_openid || author.id || '')
5
+ }
6
+
7
+ function authorName(author: QQAuthor = {}): string {
8
+ return String(author.username || author.nickname || '').trim()
9
+ }
10
+
11
+ /** Replace QQ's stable mention tokens with the best known display name. */
12
+ export function renderQQMentionNames(
13
+ text: string,
14
+ data: QQDispatchData,
15
+ lookup: (platformUserId: string) => string | undefined = () => undefined,
16
+ ): string {
17
+ const mentions = Array.isArray(data.mentions) ? data.mentions : []
18
+ let rendered = String(text || '')
19
+ for (const item of mentions) {
20
+ if (!item || typeof item !== 'object') continue
21
+ const id = String(item.user_openid || item.member_openid || item.openid || item.id || '').trim()
22
+ if (!id) continue
23
+ const name = String(item.username || item.nickname || item.display_name || item.name || lookup(id) || '').trim()
24
+ if (!name) continue
25
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
26
+ rendered = rendered
27
+ .replace(new RegExp(`<@!?${escaped}>`, 'g'), `@${name}`)
28
+ .replace(new RegExp(`@${escaped}(?![A-Za-z0-9_-])`, 'g'), `@${name}`)
29
+ }
30
+ return rendered
31
+ }
32
+
33
+ export function messageMentionsBot(data: QQDispatchData, eventType: string): boolean {
34
+ if (!Object.prototype.hasOwnProperty.call(data, 'mentions')) {
35
+ return eventType === 'GROUP_AT_MESSAGE_CREATE'
36
+ }
37
+ const mentions = data.mentions
38
+ if (!Array.isArray(mentions)) return false
39
+ if (mentions.length === 0) return eventType === 'GROUP_AT_MESSAGE_CREATE'
40
+ for (const item of mentions) {
41
+ if (!item || typeof item !== 'object' || item.bot !== true) continue
42
+ if (Object.prototype.hasOwnProperty.call(item, 'is_you')) return item.is_you === true
43
+ return true
44
+ }
45
+ return false
46
+ }
47
+
48
+ export function botMentionId(data: QQDispatchData, eventType: string): string {
49
+ const mentions = data.mentions
50
+ if (Array.isArray(mentions)) {
51
+ for (const item of mentions) {
52
+ if (!item || typeof item !== 'object' || item.bot !== true) continue
53
+ for (const key of ['id', 'user_openid', 'member_openid', 'openid'] as const) {
54
+ if (item[key]) return String(item[key])
55
+ }
56
+ }
57
+ }
58
+ if (eventType === 'GROUP_AT_MESSAGE_CREATE') {
59
+ const match = String(data.content || '').match(/<@!?([^>]+)>/)
60
+ if (match?.[1]) return match[1]
61
+ }
62
+ return ''
63
+ }
64
+
65
+ export function normalizeQQDispatch(
66
+ eventType: string,
67
+ data: QQDispatchData | undefined,
68
+ accountId: number,
69
+ ): QQNormalizedMessage | undefined {
70
+ if (!data || typeof data !== 'object') return undefined
71
+ if (eventType === 'C2C_MESSAGE_CREATE') {
72
+ const senderId = authorId(data.author)
73
+ if (!senderId) return undefined
74
+ return {
75
+ platform: 'qq', accountId, chatType: 'c2c', chatId: senderId,
76
+ senderId, senderName: authorName(data.author), groupOpenid: undefined,
77
+ messageId: String(data.id || ''), msgIdx: messageSceneIndex(data), refMsgIdx: messageReferenceIndex(data), text: String(data.content || '').trim(),
78
+ quotedText: extractQuotedText(data), quote: extractQuote(data), attachments: extractAttachments(data), mentioned: true,
79
+ botMentionId: '', raw: data,
80
+ }
81
+ }
82
+ if (eventType === 'GROUP_AT_MESSAGE_CREATE' || eventType === 'GROUP_MESSAGE_CREATE') {
83
+ const senderId = authorId(data.author)
84
+ const groupOpenid = String(data.group_openid || '')
85
+ if (!senderId || !groupOpenid) return undefined
86
+ return {
87
+ platform: 'qq', accountId, chatType: 'group', chatId: groupOpenid,
88
+ senderId, senderName: authorName(data.author), groupOpenid,
89
+ messageId: String(data.id || ''), msgIdx: messageSceneIndex(data), refMsgIdx: messageReferenceIndex(data), text: String(data.content || '').trim(),
90
+ quotedText: extractQuotedText(data), quote: extractQuote(data), attachments: extractAttachments(data), mentioned: messageMentionsBot(data, eventType),
91
+ botMentionId: botMentionId(data, eventType), raw: data,
92
+ }
93
+ }
94
+ return undefined
95
+ }
96
+
97
+ function extractQuotedText(data: QQDispatchData): string {
98
+ const reference = data.message_reference || data.reference || data.quote
99
+ if (reference && typeof reference === 'object') {
100
+ const text = String(reference.content || reference.text || '').trim()
101
+ if (text) return decodeQQText(text)
102
+ }
103
+
104
+ // QQ's native reply payload uses message_scene.ext/ref_msg_idx and keeps the
105
+ // referenced message in msg_elements instead of message_reference.
106
+ const elements = data.msg_elements || []
107
+ if (elements.length === 0) return ''
108
+ const ext = Array.isArray(data.message_scene?.ext) ? data.message_scene.ext : []
109
+ const referenceIndex = messageReferenceIndex(data)
110
+ const ownIndex = sceneValue(ext, 'msg_idx')
111
+ const element = referenceIndex
112
+ ? elements.find(item => String(item.msg_idx || '') === referenceIndex)
113
+ : elements.find(item => String(item.msg_idx || '') !== ownIndex)
114
+ if (!element) return ''
115
+ return decodeQQText(String(element.content || element.text || '').trim())
116
+ }
117
+
118
+ function extractQuote(data: QQDispatchData): QQQuoteInput | undefined {
119
+ const text = extractQuotedText(data)
120
+ const reference = data.message_reference || data.reference || data.quote
121
+ let record = reference && typeof reference === 'object' ? reference : undefined
122
+ if (!record && Array.isArray(data.msg_elements)) {
123
+ const ext = Array.isArray(data.message_scene?.ext) ? data.message_scene.ext : []
124
+ const referenceIndex = messageReferenceIndex(data)
125
+ const element = referenceIndex ? data.msg_elements.find(item => String(item.msg_idx || '') === referenceIndex) : undefined
126
+ if (element) record = element
127
+ }
128
+ const messageId = String(record?.id || record?.message_id || record?.messageId || '').trim() || undefined
129
+ const sender = record?.author && typeof record.author === 'object' ? record.author as QQAuthor : undefined
130
+ const senderId = String(sender?.user_openid || sender?.member_openid || sender?.id || record?.sender_id || '').trim() || undefined
131
+ const senderName = String(sender?.username || sender?.nickname || record?.sender_name || '').trim() || undefined
132
+ const attachments = extractAttachments(record as QQDispatchData | undefined, true)
133
+ if (!text && !messageId && !senderId && attachments.length === 0) return undefined
134
+ return { messageId, senderId, senderName, text, attachments }
135
+ }
136
+
137
+ function messageSceneIndex(data: QQDispatchData): string | undefined {
138
+ const ext = Array.isArray(data.message_scene?.ext) ? data.message_scene.ext : []
139
+ return sceneValue(ext, 'msg_idx') || String(data.id || '').trim() || undefined
140
+ }
141
+
142
+ function messageReferenceIndex(data: QQDispatchData): string | undefined {
143
+ const ext = Array.isArray(data.message_scene?.ext) ? data.message_scene.ext : []
144
+ return sceneValue(ext, 'ref_msg_idx') || sceneValue(ext, 'msg_ref_idx') || undefined
145
+ }
146
+
147
+ function extractAttachments(data: QQDispatchData | undefined, quoted = false): QQAttachmentInput[] {
148
+ if (!data) return []
149
+ const values: Array<Record<string, unknown>> = []
150
+ if (Array.isArray(data.attachments)) values.push(...data.attachments.filter(isRecord))
151
+ if (Array.isArray(data.msg_elements)) {
152
+ const ext = Array.isArray(data.message_scene?.ext) ? data.message_scene.ext : []
153
+ const referenceIndex = sceneValue(ext, 'ref_msg_idx') || sceneValue(ext, 'msg_ref_idx')
154
+ for (const element of data.msg_elements) {
155
+ if (!quoted && referenceIndex && String(element.msg_idx || '') === referenceIndex) continue
156
+ const type = String(element.type || element.element_type || element.kind || '').toLowerCase()
157
+ if (type && !/(image|photo|picture|file|video|audio|voice|record|media|attachment)/u.test(type)) continue
158
+ if (isRecord(element.attachment)) values.push(element.attachment)
159
+ else values.push(element)
160
+ }
161
+ }
162
+ const output: QQAttachmentInput[] = []
163
+ const seen = new Set<string>()
164
+ for (const value of values) {
165
+ const sourceUrl = firstString(value, ['url', 'file_url', 'download_url', 'downloadUrl', 'href', 'file'])
166
+ const platformFileId = firstString(value, ['file_id', 'fileid', 'fileId', 'id'])
167
+ if (!sourceUrl && !platformFileId && !isRecord(value.attachment)) continue
168
+ const filename = firstString(value, ['filename', 'file_name', 'name']) || inferFilename(sourceUrl) || 'QQ附件'
169
+ const contentType = firstString(value, ['content_type', 'contentType', 'mime', 'mime_type', 'type'])
170
+ const kind = mediaKind(contentType, filename, String(value.type || value.element_type || value.kind || ''))
171
+ const key = `${platformFileId || ''}|${sourceUrl || ''}|${filename}|${kind}`
172
+ if (seen.has(key)) continue
173
+ seen.add(key)
174
+ output.push({
175
+ sourceUrl, filename, contentType, platformFileId,
176
+ size: numberValue(value, ['size', 'file_size', 'bytes']),
177
+ width: numberValue(value, ['width', 'img_width']),
178
+ height: numberValue(value, ['height', 'img_height']),
179
+ durationMs: numberValue(value, ['duration_ms', 'duration']), quoted, kind,
180
+ })
181
+ }
182
+ return output
183
+ }
184
+
185
+ function isRecord(value: unknown): value is Record<string, unknown> {
186
+ return typeof value === 'object' && value !== null
187
+ }
188
+
189
+ function firstString(value: Record<string, unknown>, keys: string[]): string | undefined {
190
+ for (const key of keys) {
191
+ const item = value[key]
192
+ if (typeof item === 'string' && item.trim()) return item.trim()
193
+ }
194
+ return undefined
195
+ }
196
+
197
+ function numberValue(value: Record<string, unknown>, keys: string[]): number | undefined {
198
+ for (const key of keys) {
199
+ const item = Number(value[key])
200
+ if (Number.isFinite(item) && item >= 0) return item
201
+ }
202
+ return undefined
203
+ }
204
+
205
+ function inferFilename(url: string | undefined): string | undefined {
206
+ if (!url) return undefined
207
+ try {
208
+ const name = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
209
+ return name && name !== '/' ? name : undefined
210
+ } catch { return undefined }
211
+ }
212
+
213
+ function mediaKind(contentType: string | undefined, filename: string, rawType: string): QQMediaKind {
214
+ const value = `${contentType || ''} ${filename} ${rawType}`.toLowerCase()
215
+ if (/voice|record|audio|\.(amr|silk|mp3|wav|ogg|m4a)(?:$|\?)/u.test(value)) return 'voice'
216
+ if (/video|\.(mp4|mov|avi|mkv|webm)(?:$|\?)/u.test(value)) return 'video'
217
+ if (/image|photo|picture|\.(png|jpe?g|gif|webp|bmp|heic)(?:$|\?)/u.test(value)) return 'image'
218
+ if (/audio/u.test(value)) return 'audio'
219
+ return 'file'
220
+ }
221
+
222
+ function sceneValue(ext: unknown[], key: string): string {
223
+ const prefix = `${key}=`
224
+ for (const item of ext) {
225
+ if (typeof item === 'string' && item.startsWith(prefix)) return item.slice(prefix.length).trim()
226
+ if (!item || typeof item !== 'object') continue
227
+ const record = item as Record<string, unknown>
228
+ if (record.key === key) return String(record.value || '').trim()
229
+ if (key in record) return String(record[key] || '').trim()
230
+ }
231
+ return ''
232
+ }
233
+
234
+ function decodeQQText(value: string): string {
235
+ return value.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&')
236
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type {} from '@deepseek-ai/dsh-agent-default-model'
5
+ import type {} from '@deepseek-ai/dsh-commands'
6
+ import type {} from '@deepseek-ai/dsh-client-connection'
7
+ import { DshQQBridge } from './session/agent-bridge.js'
8
+ import { resolveConfig } from './config.js'
9
+ import { QQChatDatabase } from './storage/db.js'
10
+ import { QQChatLogger } from './shared/logging.js'
11
+ import { MemoryEngine } from './storage/memory.js'
12
+ import { QQMediaStore } from './media/store.js'
13
+ import { QQApiClient } from './gateway/api.js'
14
+ import { QQBindService } from './gateway/auth.js'
15
+ import { createQQChatRpc } from './transport/rpc.js'
16
+ import { QQChatRuntime } from './session/runtime.js'
17
+ import type { LoggerLike, QQChatConfigInput } from './types.js'
18
+
19
+ export const name = 'dsh-qqchat'
20
+ export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'] as const
21
+
22
+ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
23
+ const config = resolveConfig(inputConfig)
24
+ mkdirSync(config.dataDir, { recursive: true, mode: 0o700 })
25
+ const db = new QQChatDatabase(join(config.dataDir, 'qqchat.sqlite'))
26
+ const baseLogger = (ctx as unknown as { logger?: LoggerLike }).logger || console
27
+ const logger = new QQChatLogger(baseLogger)
28
+ const api = new QQApiClient(db, config)
29
+ const auth = new QQBindService(db, config)
30
+ const memory = new MemoryEngine(ctx, db, config, logger)
31
+ const media = new QQMediaStore(db, ctx, logger)
32
+ const bridge = new DshQQBridge(ctx, db, memory, config, logger)
33
+ const runtime = new QQChatRuntime(ctx, db, api, auth, memory, bridge, config, logger, media)
34
+
35
+ ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime), { authority: 'loopback' })
36
+ ctx.effect(() => {
37
+ void runtime.start().catch(error => logger.error?.(error))
38
+ return () => runtime.stop()
39
+ }, 'dsh-qqchat runtime')
40
+ }
41
+
42
+ export type { QQChatConfig, QQChatConfigInput } from './types.js'
43
+ export { QQChatDatabase } from './storage/db.js'
44
+ export { normalizeQQDispatch } from './gateway/normalize.js'
45
+
46
+ export default { name, inject, apply }
@@ -0,0 +1,62 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import type { QQChatDatabase } from '../storage/db.js'
4
+
5
+ /** Exposes stored QQ images through DSH's durable image-block seam. */
6
+ export function registerQQImageTool(ctx: Context, db: QQChatDatabase, isAllowed: (agentId: string, attachmentId: string) => boolean = () => true): () => void {
7
+ const definition = defineTool({
8
+ name: 'qqchat_describe_image',
9
+ description: '查看 QQ 消息中的图片。传入 QQChat 提供的 attachment_id,可用于 OCR、截图、界面和照片分析。',
10
+ parameters: {
11
+ attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
12
+ },
13
+ output: {
14
+ schema: {
15
+ type: 'object',
16
+ properties: {
17
+ ok: { type: 'boolean' },
18
+ attachment_id: { type: 'string' },
19
+ filename: { type: 'string' },
20
+ media_type: { type: 'string' },
21
+ bytes: { type: 'number' },
22
+ image_ref: { type: 'json' },
23
+ error: { type: 'string' },
24
+ },
25
+ additionalProperties: false,
26
+ },
27
+ render: (_args, value) => {
28
+ if (!value.ok) return [{ type: 'text', text: value.error || '图片不可用。' }]
29
+ if (!value.image_ref || typeof value.image_ref !== 'object') return [{ type: 'text', text: '图片已找到,但当前 DSH 没有可用的图片附件服务。' }]
30
+ return [
31
+ { type: 'text', text: `已加载图片:${value.filename || value.attachment_id}` },
32
+ { type: 'image', attachment: value.image_ref as never },
33
+ ]
34
+ },
35
+ },
36
+ async execute(args, exec) {
37
+ const attachment = db.attachmentById(args.attachment_id)
38
+ if (!attachment || attachment.kind !== 'image' || !attachment.imageRef) {
39
+ return { ok: false, attachment_id: args.attachment_id, error: '找不到可查看的 QQ 图片附件。' }
40
+ }
41
+ const agent = exec.agent as unknown as { id?: string; options?: { provider?: string; model?: string } } | undefined
42
+ if (!isAllowed(String(agent?.id || ''), args.attachment_id)) {
43
+ return { ok: false, attachment_id: args.attachment_id, error: '当前 QQ 消息没有权限访问这张图片。' }
44
+ }
45
+ if (agent?.options?.provider && agent.options.model) {
46
+ try {
47
+ const info = await ctx.llm.resolveModelInfo(agent.options.provider, agent.options.model, exec.signal)
48
+ if (!info.inputModalities?.includes('image')) {
49
+ return { ok: false, attachment_id: args.attachment_id, error: '当前模型不支持图片输入。' }
50
+ }
51
+ } catch {
52
+ return { ok: false, attachment_id: args.attachment_id, error: '无法确认当前模型是否支持图片输入。' }
53
+ }
54
+ }
55
+ return {
56
+ ok: true, attachment_id: attachment.id, filename: attachment.filename,
57
+ media_type: attachment.imageRef.mediaType, bytes: attachment.sizeBytes, image_ref: { ...attachment.imageRef },
58
+ }
59
+ },
60
+ })
61
+ return ctx.tools.register(definition)
62
+ }
@@ -0,0 +1,66 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+ import { execFile as execFileCallback } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import type { Context } from '@deepseek-ai/cordis'
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import type { QQChatDatabase } from '../storage/db.js'
7
+
8
+ const execFile = promisify(execFileCallback)
9
+ const MAX_TEXT_BYTES = 2 * 1024 * 1024
10
+
11
+ export function registerQQMediaTools(
12
+ ctx: Context,
13
+ db: QQChatDatabase,
14
+ isAllowed: (agentId: string, attachmentId: string) => boolean = () => true,
15
+ ): () => void {
16
+ const readFileTool = defineTool({
17
+ name: 'qqchat_read_file',
18
+ description: '读取 QQ 消息中的文本文件。传入 QQChat 提供的 attachment_id;二进制文件不会被当作文本读取。',
19
+ parameters: { attachment_id: { type: 'string', required: true, description: 'QQChat 文件附件 ID' } },
20
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
21
+ async execute(args, exec) {
22
+ const attachment = db.attachmentById(args.attachment_id)
23
+ const agentId = String((exec.agent as unknown as { id?: string } | undefined)?.id || '')
24
+ if (!attachment || !attachment.localPath || !isAllowed(agentId, args.attachment_id)) return '当前 QQ 消息没有可读取的文件附件。'
25
+ if (attachment.kind !== 'file') return '这个附件不是普通文本文件。'
26
+ if (attachment.sizeBytes > MAX_TEXT_BYTES) return `文件过大,超过 ${MAX_TEXT_BYTES} 字节读取上限。`
27
+ try {
28
+ return await readFile(attachment.localPath, { encoding: 'utf8', signal: exec.signal })
29
+ } catch {
30
+ return '文件读取失败或文件已过期。'
31
+ }
32
+ },
33
+ })
34
+
35
+ const mediaInfoTool = defineTool({
36
+ name: 'qqchat_media_info',
37
+ description: '查看 QQ 图片、语音、视频或文件附件的元数据;视频可在服务器安装 ffprobe 时补充流信息。',
38
+ parameters: { attachment_id: { type: 'string', required: true, description: 'QQChat 媒体附件 ID' } },
39
+ output: {
40
+ schema: { type: 'object', properties: { ok: { type: 'boolean' }, info: { type: 'string' }, error: { type: 'string' } }, additionalProperties: false },
41
+ render: (_args, value) => [{ type: 'text', text: value.ok ? (value.info || '{}') : (value.error || '媒体信息不可用。') }],
42
+ },
43
+ async execute(args, exec) {
44
+ const attachment = db.attachmentById(args.attachment_id)
45
+ const agentId = String((exec.agent as unknown as { id?: string } | undefined)?.id || '')
46
+ if (!attachment || !isAllowed(agentId, args.attachment_id)) return { ok: false, error: '当前 QQ 消息没有权限访问这个附件。' }
47
+ const info: Record<string, unknown> = {
48
+ attachmentId: attachment.id, kind: attachment.kind, filename: attachment.filename,
49
+ contentType: attachment.contentType || null, sizeBytes: attachment.sizeBytes,
50
+ }
51
+ if (attachment.localPath) {
52
+ try { info.fileSize = Number((await stat(attachment.localPath)).size) } catch {}
53
+ if (attachment.kind === 'video' || attachment.kind === 'voice' || attachment.kind === 'audio') {
54
+ try {
55
+ const result = await execFile('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', attachment.localPath], { timeout: 10_000, signal: exec.signal })
56
+ info.ffprobe = JSON.parse(result.stdout)
57
+ } catch { info.ffprobe = 'ffprobe 不可用或媒体格式无法探测。' }
58
+ }
59
+ }
60
+ return { ok: true, info: JSON.stringify(info, null, 2) }
61
+ },
62
+ })
63
+ const disposeRead = ctx.tools.register(readFileTool)
64
+ const disposeInfo = ctx.tools.register(mediaInfoTool)
65
+ return () => { disposeInfo(); disposeRead() }
66
+ }
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { access, mkdir, rm, writeFile } from 'node:fs/promises'
3
+ import { basename, dirname, extname, join } from 'node:path'
4
+ import { lookup } from 'node:dns/promises'
5
+ import { isIP } from 'node:net'
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import type { LoggerLike, QQAttachmentInput, QQMediaKind, StoredAttachmentSummary } from '../types.js'
8
+ import type { QQChatDatabase } from '../storage/db.js'
9
+
10
+ const MAX_FILE_BYTES = 50 * 1024 * 1024
11
+ const MAX_MESSAGE_BYTES = 100 * 1024 * 1024
12
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000
13
+
14
+ export class QQMediaStore {
15
+ readonly root: string
16
+
17
+ constructor(private readonly db: QQChatDatabase, private readonly ctx?: Context, private readonly logger: LoggerLike = console) {
18
+ this.root = join(dirname(db.path), 'media')
19
+ }
20
+
21
+ async ingest(accountId: number, messageId: string, attachments: QQAttachmentInput[]): Promise<StoredAttachmentSummary[]> {
22
+ const result: StoredAttachmentSummary[] = []
23
+ let messageBytes = 0
24
+ for (const [sourceIndex, input] of attachments.slice(0, 12).entries()) {
25
+ try {
26
+ if (input.attachmentId) {
27
+ const existingById = this.db.attachmentById(input.attachmentId)
28
+ if (existingById?.localPath) {
29
+ try {
30
+ await access(existingById.localPath)
31
+ this.db.extendAttachment(existingById.id, Date.now() + RETENTION_MS)
32
+ result.push({ ...existingById, quoted: Boolean(input.quoted), sourceFileId: input.platformFileId || existingById.sourceFileId, sourceIndex })
33
+ continue
34
+ } catch {}
35
+ }
36
+ }
37
+ const reusable = this.db.findReusableAttachment(accountId, messageId, input.platformFileId, input.kind)
38
+ if (reusable?.localPath) {
39
+ try {
40
+ await access(reusable.localPath)
41
+ this.db.extendAttachment(reusable.id, Date.now() + RETENTION_MS)
42
+ result.push({ ...reusable, quoted: Boolean(input.quoted), sourceFileId: input.platformFileId || reusable.sourceFileId, sourceIndex })
43
+ continue
44
+ } catch {}
45
+ }
46
+ const downloaded = await this.download(input, messageBytes)
47
+ if (!downloaded) continue
48
+ messageBytes += downloaded.data.byteLength
49
+ const id = `qqatt-${randomUUID()}`
50
+ const extension = safeExtension(input.filename, input.contentType)
51
+ await mkdir(this.root, { recursive: true, mode: 0o700 })
52
+ const path = join(this.root, `${id}${extension}`)
53
+ await writeFile(path, downloaded.data, { mode: 0o600 })
54
+ const imageRef = await this.saveImageRef(input, downloaded.data)
55
+ const kind = input.kind || inferKind(input.contentType, input.filename)
56
+ const summary: StoredAttachmentSummary = {
57
+ id, kind, filename: safeFilename(input.filename), contentType: input.contentType,
58
+ sizeBytes: downloaded.data.byteLength, quoted: Boolean(input.quoted), sourceFileId: input.platformFileId, sourceIndex, localPath: path, imageRef,
59
+ }
60
+ this.db.saveAttachment({ id, accountId, sourceMessageId: messageId, sourceFileId: input.platformFileId,
61
+ kind, filename: summary.filename, contentType: input.contentType, sizeBytes: summary.sizeBytes,
62
+ localPath: path, imageRef, expiresAt: Date.now() + RETENTION_MS })
63
+ result.push(summary)
64
+ } catch (error) {
65
+ this.logger.warn?.(`[dsh-qqchat] QQ media ingest failed for ${safeFilename(input.filename)}: ${error instanceof Error ? error.message : String(error)}`)
66
+ // Media is optional input. The text turn must remain usable when a download fails.
67
+ }
68
+ }
69
+ return result
70
+ }
71
+
72
+ async cleanup(): Promise<void> {
73
+ const cutoff = Date.now()
74
+ this.db.expireQuoteIndex(cutoff)
75
+ this.db.expireAttachments(cutoff)
76
+ for (const path of this.db.expiredAttachmentPaths(cutoff)) {
77
+ try {
78
+ await rm(path, { force: true })
79
+ this.db.markAttachmentDeleted(path)
80
+ } catch {
81
+ // Keep the expired row for the next sweep if the physical object is busy.
82
+ }
83
+ }
84
+ await mkdir(this.root, { recursive: true, mode: 0o700 })
85
+ }
86
+
87
+ private async download(input: QQAttachmentInput, currentBytes: number): Promise<{ data: Uint8Array } | undefined> {
88
+ const source = input.sourceUrl
89
+ if (!source) return undefined
90
+ let url = source
91
+ for (let redirect = 0; redirect <= 3; redirect++) {
92
+ const parsed = new URL(url)
93
+ if (parsed.protocol !== 'https:') throw new Error('QQ media URL must use HTTPS')
94
+ await assertPublicHost(parsed.hostname)
95
+ const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(20_000) })
96
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
97
+ const location = response.headers.get('location')
98
+ if (!location) return undefined
99
+ url = new URL(location, url).toString()
100
+ continue
101
+ }
102
+ if (!response.ok) return undefined
103
+ const length = Number(response.headers.get('content-length') || 0)
104
+ if (length > MAX_FILE_BYTES || currentBytes + length > MAX_MESSAGE_BYTES) throw new Error('QQ media size limit exceeded')
105
+ const data = new Uint8Array(await response.arrayBuffer())
106
+ if (data.byteLength > MAX_FILE_BYTES || currentBytes + data.byteLength > MAX_MESSAGE_BYTES) throw new Error('QQ media size limit exceeded')
107
+ return { data }
108
+ }
109
+ return undefined
110
+ }
111
+
112
+ private async saveImageRef(input: QQAttachmentInput, data: Uint8Array): Promise<StoredAttachmentSummary['imageRef'] | undefined> {
113
+ const service = this.ctx?.get('attachments') as { saveImage(input: { data: Uint8Array; mediaType: string; name?: string }): Promise<StoredAttachmentSummary['imageRef']> } | undefined
114
+ const mediaType = sniffImageType(data)
115
+ if (!service || !mediaType) return undefined
116
+ try { return await service.saveImage({ data, mediaType, name: safeFilename(input.filename) }) }
117
+ catch { return undefined }
118
+ }
119
+ }
120
+
121
+ async function assertPublicHost(hostname: string): Promise<void> {
122
+ const addresses = isIP(hostname) ? [hostname] : (await lookup(hostname, { all: true })).map(item => item.address)
123
+ for (const address of addresses) {
124
+ if (isPrivateAddress(address)) throw new Error('QQ media URL resolves to a private address')
125
+ }
126
+ }
127
+
128
+ function isPrivateAddress(address: string): boolean {
129
+ if (address === '::1' || address === '0.0.0.0' || address === '::') return true
130
+ if (address.includes(':')) return address === '::1' || address.toLowerCase().startsWith('fc') || address.toLowerCase().startsWith('fd') || address.toLowerCase().startsWith('fe8') || address.toLowerCase().startsWith('fe9') || address.toLowerCase().startsWith('fea') || address.toLowerCase().startsWith('feb')
131
+ const parts = address.split('.').map(Number)
132
+ const [a, b] = parts
133
+ return parts.length === 4 && (a === 10 || a === 127 || (a === 172 && b !== undefined && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254))
134
+ }
135
+
136
+ function sniffImageType(data: Uint8Array): 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' | undefined {
137
+ if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) return 'image/png'
138
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
139
+ if (data.length >= 6 && new TextDecoder().decode(data.slice(0, 6)) === 'GIF89a') return 'image/gif'
140
+ if (data.length >= 12 && new TextDecoder().decode(data.slice(0, 4)) === 'RIFF' && new TextDecoder().decode(data.slice(8, 12)) === 'WEBP') return 'image/webp'
141
+ return undefined
142
+ }
143
+
144
+ function safeFilename(value: string): string { return basename(value || 'QQ附件').replace(/[^\p{L}\p{N}._-]+/gu, '_').slice(0, 160) || 'QQ附件' }
145
+ function safeExtension(filename: string, contentType?: string): string {
146
+ const ext = extname(filename).replace(/[^a-zA-Z0-9.]/g, '').slice(0, 12)
147
+ if (ext) return ext.toLowerCase()
148
+ const typeExtension = contentType?.split('/')[1]
149
+ return typeExtension ? `.${typeExtension.replace(/[^a-zA-Z0-9]/g, '')}` : '.bin'
150
+ }
151
+ function inferKind(contentType?: string, filename = ''): QQMediaKind {
152
+ const value = `${contentType || ''} ${filename}`.toLowerCase()
153
+ if (value.includes('video') || /\.(mp4|mov|avi|mkv|webm)$/u.test(value)) return 'video'
154
+ if (value.includes('audio') || /\.(mp3|wav|ogg|m4a|amr|silk)$/u.test(value)) return 'voice'
155
+ if (value.includes('image') || /\.(png|jpe?g|gif|webp|bmp|heic)$/u.test(value)) return 'image'
156
+ return 'file'
157
+ }