@xmanrui/dsh-im 0.9.0 → 0.10.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.
@@ -1,3 +1,5 @@
1
+ import { ImagePromptError } from '../shared/image-prompt.mjs';
2
+
1
3
  export function conversationKey(event) {
2
4
  const chatType = event?.message?.chat_type;
3
5
  if (chatType === 'p2p') {
@@ -10,19 +12,151 @@ export function conversationKey(event) {
10
12
  return `group:${chatId}`;
11
13
  }
12
14
 
13
- export function extractText(event) {
14
- if (event?.message?.message_type !== 'text') return null;
15
- let parsed;
15
+ function parsedMessageContent(event) {
16
+ const value = event?.message?.content;
17
+ if (value && typeof value === 'object') return value;
18
+ if (typeof value !== 'string') return null;
16
19
  try {
17
- parsed = JSON.parse(event.message.content);
20
+ const parsed = JSON.parse(value);
21
+ return parsed && typeof parsed === 'object' ? parsed : null;
18
22
  } catch {
19
23
  return null;
20
24
  }
21
- let text = typeof parsed.text === 'string' ? parsed.text : '';
22
- for (const mention of event.message.mentions ?? []) {
23
- if (typeof mention.key === 'string' && mention.key) text = text.replaceAll(mention.key, '');
25
+ }
26
+
27
+ function withoutMentions(text, event) {
28
+ let result = typeof text === 'string' ? text : '';
29
+ for (const mention of event?.message?.mentions ?? []) {
30
+ if (typeof mention?.key === 'string' && mention.key) {
31
+ result = result.replaceAll(mention.key, '');
32
+ }
33
+ }
34
+ return result.trim();
35
+ }
36
+
37
+ export function extractText(event) {
38
+ if (event?.message?.message_type !== 'text') return null;
39
+ const parsed = parsedMessageContent(event);
40
+ return parsed ? withoutMentions(parsed.text, event) : null;
41
+ }
42
+
43
+ function nonEmptyString(value) {
44
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
45
+ }
46
+
47
+ function postContent(event, parsed = parsedMessageContent(event)) {
48
+ if (event?.message?.message_type !== 'post') return null;
49
+ if (!parsed) return null;
50
+
51
+ const lines = [];
52
+ const title = nonEmptyString(withoutMentions(parsed.title, event));
53
+ if (title) lines.push(title);
54
+ const imageKeys = [];
55
+ for (const paragraph of Array.isArray(parsed.content) ? parsed.content : []) {
56
+ if (!Array.isArray(paragraph)) continue;
57
+ let visibleText = '';
58
+ for (const element of paragraph) {
59
+ const tag = String(element?.tag ?? '').toLowerCase();
60
+ if (tag === 'img') {
61
+ const key = nonEmptyString(element?.image_key);
62
+ if (key) imageKeys.push(key);
63
+ } else if (tag === 'text' || tag === 'a' || tag === 'link') {
64
+ if (typeof element?.text === 'string') visibleText += element.text;
65
+ }
66
+ }
67
+ const line = nonEmptyString(withoutMentions(visibleText, event));
68
+ if (line) lines.push(line);
69
+ }
70
+
71
+ return {
72
+ text: lines.join('\n'),
73
+ imageKeys,
74
+ };
75
+ }
76
+
77
+ function headerValue(headers, name) {
78
+ if (typeof headers?.get === 'function') return headers.get(name);
79
+ return headers?.[name] ?? headers?.[name.toLowerCase()] ?? null;
80
+ }
81
+
82
+ function declaredSize(headers) {
83
+ const header = headerValue(headers, 'content-length');
84
+ if (header === null || header === undefined || header === '') return null;
85
+ const value = Number(header);
86
+ return Number.isFinite(value) && value >= 0 ? value : null;
87
+ }
88
+
89
+ async function readBoundedStream(stream, { signal, maxBytes }) {
90
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
91
+ throw new Error('Feishu image download returned no readable stream');
24
92
  }
25
- return text.trim();
93
+ signal?.throwIfAborted();
94
+ const abort = () => stream.destroy?.(
95
+ signal.reason ?? new DOMException('Feishu image download aborted', 'AbortError'),
96
+ );
97
+ signal?.addEventListener('abort', abort, { once: true });
98
+ const chunks = [];
99
+ let size = 0;
100
+ try {
101
+ for await (const chunk of stream) {
102
+ signal?.throwIfAborted();
103
+ const data = Buffer.from(chunk);
104
+ size += data.length;
105
+ if (size > maxBytes) {
106
+ stream.destroy?.();
107
+ throw new ImagePromptError(
108
+ 'image-too-large',
109
+ `Feishu image exceeds ${maxBytes} bytes`,
110
+ '图片超过 5 MB,请压缩后重试。',
111
+ );
112
+ }
113
+ chunks.push(data);
114
+ }
115
+ signal?.throwIfAborted();
116
+ return Buffer.concat(chunks, size);
117
+ } finally {
118
+ signal?.removeEventListener('abort', abort);
119
+ }
120
+ }
121
+
122
+ function feishuImageSource(event, client, key) {
123
+ return {
124
+ async load({ signal, maxBytes }) {
125
+ signal?.throwIfAborted();
126
+ const resource = await client?.im?.v1?.messageResource?.get?.({
127
+ path: {
128
+ message_id: event.message.message_id,
129
+ file_key: key,
130
+ },
131
+ params: { type: 'image' },
132
+ });
133
+ signal?.throwIfAborted();
134
+ const size = declaredSize(resource?.headers);
135
+ if (size !== null && size > maxBytes) {
136
+ resource?.getReadableStream?.().destroy?.();
137
+ throw new ImagePromptError(
138
+ 'image-too-large',
139
+ `Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
140
+ '图片超过 5 MB,请压缩后重试。',
141
+ );
142
+ }
143
+ return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
144
+ },
145
+ };
146
+ }
147
+
148
+ export function extractInboundMessage(event, client) {
149
+ const messageType = event?.message?.message_type;
150
+ const parsed = parsedMessageContent(event);
151
+ const post = postContent(event, parsed);
152
+ const standaloneImageKey = messageType === 'image'
153
+ ? nonEmptyString(parsed?.image_key)
154
+ : null;
155
+ const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
156
+ return {
157
+ content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
158
+ images: imageKeys.map((key) => feishuImageSource(event, client, key)),
159
+ };
26
160
  }
27
161
 
28
162
  export function splitText(text, maxChars = 9000) {
@@ -5,6 +5,7 @@ export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
5
5
  export const REQUIRED_TENANT_SCOPES = Object.freeze([
6
6
  'im:message.p2p_msg:readonly',
7
7
  'im:message.group_at_msg:readonly',
8
+ 'im:message:readonly',
8
9
  'im:message:send_as_bot',
9
10
  'im:message.reactions:write_only',
10
11
  'im:message:recall',
@@ -7,13 +7,30 @@ import {
7
7
  } from '../shared/harness-question.mjs';
8
8
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
9
9
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
10
+ import {
11
+ fetchImageBuffer,
12
+ hasInboundImages,
13
+ imagePromptUserMessage,
14
+ promptContentForMessage,
15
+ } from '../shared/image-prompt.mjs';
10
16
 
11
17
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
12
18
 
19
+ export const QQ_IMAGE_HOSTS = Object.freeze([
20
+ '.myqcloud.com',
21
+ '.qpic.cn',
22
+ '.qq.com',
23
+ '.qq.com.cn',
24
+ '.tencentcos.com',
25
+ '.ugcimg.cn',
26
+ ]);
27
+
28
+ const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
29
+
13
30
  const HELP_TEXT = [
14
31
  'QQ 机器人已连接 DeepSeek Harness。',
15
32
  '',
16
- '直接发送文字即可继续当前会话。',
33
+ '直接发送文字或图片即可继续当前会话。',
17
34
  '/new 开启一个全新会话',
18
35
  '/compact 压缩当前会话的较早上下文',
19
36
  '/workspace 工作区绝对路径 切换工作区',
@@ -32,6 +49,51 @@ function safeText(message) {
32
49
  return typeof message?.content === 'string' ? message.content.trim() : '';
33
50
  }
34
51
 
52
+ function attachmentMediaType(attachment) {
53
+ const value = nonEmptyString(attachment?.content_type ?? attachment?.contentType);
54
+ if (!value) return null;
55
+ return value.split(';', 1)[0].trim().toLowerCase();
56
+ }
57
+
58
+ function isQqImageAttachment(attachment) {
59
+ const mediaType = attachmentMediaType(attachment);
60
+ return mediaType?.startsWith('image/') === true
61
+ || QQ_IMAGE_FILENAME.test(nonEmptyString(attachment?.filename) ?? '');
62
+ }
63
+
64
+ function hasQqImageAttachments(message) {
65
+ return Array.isArray(message?.attachments)
66
+ && message.attachments.some(isQqImageAttachment);
67
+ }
68
+
69
+ /** Convert QQ's attachment metadata into lazily downloaded image references. */
70
+ export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
71
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
72
+ const images = [];
73
+ for (const attachment of message?.attachments ?? []) {
74
+ if (!isQqImageAttachment(attachment)) continue;
75
+ const url = nonEmptyString(attachment?.url);
76
+ const name = nonEmptyString(attachment?.filename) ?? undefined;
77
+ const mediaType = attachmentMediaType(attachment);
78
+ const declaredSize = Number(attachment?.size);
79
+ images.push({
80
+ ...(name ? { name } : {}),
81
+ ...(mediaType?.startsWith('image/') ? { mediaType } : {}),
82
+ ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
83
+ load: ({ signal, maxBytes }) => {
84
+ if (!url) throw new Error('QQ image attachment has no download URL');
85
+ return fetchImageBuffer(url, {
86
+ fetchImpl,
87
+ signal,
88
+ maxBytes,
89
+ allowedHosts: QQ_IMAGE_HOSTS,
90
+ });
91
+ },
92
+ });
93
+ }
94
+ return { content: safeText(message), images };
95
+ }
96
+
35
97
  function nonEmptyString(value) {
36
98
  return typeof value === 'string' && value.trim() ? value.trim() : null;
37
99
  }
@@ -40,6 +102,7 @@ function canClaimInteractionReply(message, pending) {
40
102
  return pending.questions[pending.index]
41
103
  && nonEmptyString(message?.senderId) === pending.actor
42
104
  && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
105
+ && !hasQqImageAttachments(message)
43
106
  && nonEmptyString(safeText(message));
44
107
  }
45
108
 
@@ -64,6 +127,7 @@ export class QqHarnessBridge {
64
127
  #logger;
65
128
  #replyTimeoutMs;
66
129
  #signal;
130
+ #fetchImpl;
67
131
  #queues = new Map();
68
132
  #pendingInteractions = new Map();
69
133
  #interactionKeys = new Map();
@@ -80,10 +144,12 @@ export class QqHarnessBridge {
80
144
  logger = console,
81
145
  replyTimeoutMs = 600_000,
82
146
  signal,
147
+ fetchImpl = fetch,
83
148
  }) {
84
149
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
85
150
  if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
86
151
  if (!harness || !state) throw new TypeError('Harness client and state store are required');
152
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
87
153
  this.#bot = bot;
88
154
  this.#ownerUserOpenid = ownerUserOpenid;
89
155
  this.#harness = harness;
@@ -92,6 +158,7 @@ export class QqHarnessBridge {
92
158
  this.#logger = logger;
93
159
  this.#replyTimeoutMs = replyTimeoutMs;
94
160
  this.#signal = signal;
161
+ this.#fetchImpl = fetchImpl;
95
162
  this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
96
163
  }
97
164
 
@@ -114,7 +181,7 @@ export class QqHarnessBridge {
114
181
  key,
115
182
  actor: sender,
116
183
  messageId,
117
- text: safeText(message),
184
+ text: hasQqImageAttachments(message) ? '' : safeText(message),
118
185
  addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
119
186
  hasPendingQuestion: Boolean(pending),
120
187
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -210,32 +277,37 @@ export class QqHarnessBridge {
210
277
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
211
278
 
212
279
  const target = message.replyTarget;
213
- const text = safeText(message);
280
+ const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
281
+ const text = promptMessage.content;
282
+ const hasImages = hasInboundImages(promptMessage);
283
+ let stream = null;
214
284
  try {
215
- if (!text) {
216
- await this.#bot.sendText(target, '目前仅支持文字消息。');
285
+ if (!text && !hasImages) {
286
+ await this.#bot.sendText(target, '目前支持文字和图片消息。');
217
287
  await this.#state.markSeen(messageId);
218
288
  return;
219
289
  }
220
290
  const command = text.toLowerCase();
221
- if (command === '/help') {
291
+ if (!hasImages && command === '/help') {
222
292
  await this.#bot.sendText(target, HELP_TEXT);
223
293
  await this.#state.markSeen(messageId);
224
294
  return;
225
295
  }
226
- if (command === '/status') {
296
+ if (!hasImages && command === '/status') {
227
297
  await this.#harness.ensureRunning({ signal: this.#signal });
228
298
  await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
229
299
  await this.#state.markSeen(messageId);
230
300
  return;
231
301
  }
232
- if (command === '/new') {
302
+ if (!hasImages && command === '/new') {
233
303
  await this.#state.clearSession(key);
234
304
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
235
305
  await this.#state.markSeen(messageId);
236
306
  return;
237
307
  }
238
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
308
+ const workspaceCommand = hasImages
309
+ ? null
310
+ : await runWorkspaceCommand(text, this.#harness, key);
239
311
  if (workspaceCommand) {
240
312
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
241
313
  await this.#bot.sendText(target, reply);
@@ -243,20 +315,24 @@ export class QqHarnessBridge {
243
315
  await this.#state.markSeen(messageId);
244
316
  return;
245
317
  }
246
- const compactCommand = await runCompactCommand(
247
- text,
248
- this.#harness,
249
- this.#state,
250
- key,
251
- { signal: this.#signal },
252
- );
318
+ const compactCommand = hasImages
319
+ ? null
320
+ : await runCompactCommand(
321
+ text,
322
+ this.#harness,
323
+ this.#state,
324
+ key,
325
+ { signal: this.#signal },
326
+ );
253
327
  if (compactCommand) {
254
328
  await this.#bot.sendText(target, compactCommand.message);
255
329
  await this.#state.markSeen(messageId);
256
330
  return;
257
331
  }
258
332
 
259
- let stream = null;
333
+ const content = hasImages
334
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
335
+ : undefined;
260
336
  let streamFinished = false;
261
337
  if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
262
338
  try {
@@ -271,7 +347,7 @@ export class QqHarnessBridge {
271
347
  harness: this.#harness,
272
348
  state: this.#state,
273
349
  key,
274
- text,
350
+ ...(hasImages ? { content } : { text }),
275
351
  createOptions: { signal: this.#signal },
276
352
  existsOptions: { signal: this.#signal },
277
353
  askOptions: {
@@ -316,11 +392,15 @@ export class QqHarnessBridge {
316
392
  this.#status.lastReplyAt = new Date().toISOString();
317
393
  this.#status.lastError = null;
318
394
  } catch (error) {
395
+ stream?.cancel?.();
319
396
  if (this.#signal?.aborted) return;
320
397
  this.#status.lastError = error?.message ?? String(error);
321
398
  this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
322
399
  try {
323
- await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
400
+ await this.#bot.sendText(
401
+ target,
402
+ imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
403
+ );
324
404
  await this.#state.markSeen(messageId);
325
405
  } catch (sendError) {
326
406
  this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
@@ -345,7 +425,7 @@ export class QqHarnessBridge {
345
425
 
346
426
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
347
427
  const text = nonEmptyString(safeText(message));
348
- if (!text) {
428
+ if (!text || hasQqImageAttachments(message)) {
349
429
  await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
350
430
  return;
351
431
  }
@@ -623,7 +623,7 @@ export class HarnessClient {
623
623
  return ownership ? { ownership, recovered: true } : null;
624
624
  }
625
625
 
626
- async ask(sessionId, text, options = {}) {
626
+ async ask(sessionId, prompt, options = {}) {
627
627
  if (typeof options === 'number') options = { timeoutMs: options };
628
628
  const timeoutMs = options.timeoutMs ?? 600_000;
629
629
  const signal = options.signal;
@@ -693,10 +693,16 @@ export class HarnessClient {
693
693
  ]);
694
694
  }
695
695
 
696
+ const content = typeof prompt === 'string'
697
+ ? [{ type: 'text', text: prompt }]
698
+ : prompt;
699
+ if (!Array.isArray(content) || content.length === 0) {
700
+ throw new TypeError('Harness prompt content is required');
701
+ }
696
702
  await this.rpc('session.prompt', {
697
703
  sessionId,
698
704
  mode: 'queue',
699
- content: [{ type: 'text', text }],
705
+ content,
700
706
  clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
701
707
  }, 30_000, { rpcId: promptRpcId, signal });
702
708