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