mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 20:00:48 +08:00
feat: support image prompts across IM channels
This commit is contained in:
parent
99a877c640
commit
65c842c045
40 changed files with 3806 additions and 274 deletions
|
|
@ -7,13 +7,30 @@ import {
|
|||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
fetchImageBuffer,
|
||||
hasInboundImages,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
|
||||
export const QQ_IMAGE_HOSTS = Object.freeze([
|
||||
'.myqcloud.com',
|
||||
'.qpic.cn',
|
||||
'.qq.com',
|
||||
'.qq.com.cn',
|
||||
'.tencentcos.com',
|
||||
'.ugcimg.cn',
|
||||
]);
|
||||
|
||||
const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
|
||||
|
||||
const HELP_TEXT = [
|
||||
'QQ 机器人已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'直接发送文字或图片即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -32,6 +49,51 @@ function safeText(message) {
|
|||
return typeof message?.content === 'string' ? message.content.trim() : '';
|
||||
}
|
||||
|
||||
function attachmentMediaType(attachment) {
|
||||
const value = nonEmptyString(attachment?.content_type ?? attachment?.contentType);
|
||||
if (!value) return null;
|
||||
return value.split(';', 1)[0].trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isQqImageAttachment(attachment) {
|
||||
const mediaType = attachmentMediaType(attachment);
|
||||
return mediaType?.startsWith('image/') === true
|
||||
|| QQ_IMAGE_FILENAME.test(nonEmptyString(attachment?.filename) ?? '');
|
||||
}
|
||||
|
||||
function hasQqImageAttachments(message) {
|
||||
return Array.isArray(message?.attachments)
|
||||
&& message.attachments.some(isQqImageAttachment);
|
||||
}
|
||||
|
||||
/** Convert QQ's attachment metadata into lazily downloaded image references. */
|
||||
export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
const images = [];
|
||||
for (const attachment of message?.attachments ?? []) {
|
||||
if (!isQqImageAttachment(attachment)) continue;
|
||||
const url = nonEmptyString(attachment?.url);
|
||||
const name = nonEmptyString(attachment?.filename) ?? undefined;
|
||||
const mediaType = attachmentMediaType(attachment);
|
||||
const declaredSize = Number(attachment?.size);
|
||||
images.push({
|
||||
...(name ? { name } : {}),
|
||||
...(mediaType?.startsWith('image/') ? { mediaType } : {}),
|
||||
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
||||
load: ({ signal, maxBytes }) => {
|
||||
if (!url) throw new Error('QQ image attachment has no download URL');
|
||||
return fetchImageBuffer(url, {
|
||||
fetchImpl,
|
||||
signal,
|
||||
maxBytes,
|
||||
allowedHosts: QQ_IMAGE_HOSTS,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
return { content: safeText(message), images };
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
|
@ -40,6 +102,7 @@ function canClaimInteractionReply(message, pending) {
|
|||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(message?.senderId) === pending.actor
|
||||
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
|
||||
&& !hasQqImageAttachments(message)
|
||||
&& nonEmptyString(safeText(message));
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +127,7 @@ export class QqHarnessBridge {
|
|||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#signal;
|
||||
#fetchImpl;
|
||||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
|
|
@ -80,10 +144,12 @@ export class QqHarnessBridge {
|
|||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
|
||||
if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
|
||||
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
this.#bot = bot;
|
||||
this.#ownerUserOpenid = ownerUserOpenid;
|
||||
this.#harness = harness;
|
||||
|
|
@ -92,6 +158,7 @@ export class QqHarnessBridge {
|
|||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#signal = signal;
|
||||
this.#fetchImpl = fetchImpl;
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +181,7 @@ export class QqHarnessBridge {
|
|||
key,
|
||||
actor: sender,
|
||||
messageId,
|
||||
text: safeText(message),
|
||||
text: hasQqImageAttachments(message) ? '' : safeText(message),
|
||||
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
|
||||
hasPendingQuestion: Boolean(pending),
|
||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||
|
|
@ -210,32 +277,37 @@ export class QqHarnessBridge {
|
|||
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
||||
|
||||
const target = message.replyTarget;
|
||||
const text = safeText(message);
|
||||
const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
|
||||
const text = promptMessage.content;
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
let stream = null;
|
||||
try {
|
||||
if (!text) {
|
||||
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
||||
if (!text && !hasImages) {
|
||||
await this.#bot.sendText(target, '目前支持文字和图片消息。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const command = text.toLowerCase();
|
||||
if (command === '/help') {
|
||||
if (!hasImages && command === '/help') {
|
||||
await this.#bot.sendText(target, HELP_TEXT);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
if (!hasImages && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
if (!hasImages && command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
const workspaceCommand = hasImages
|
||||
? null
|
||||
: await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
|
|
@ -243,20 +315,24 @@ export class QqHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const compactCommand = hasImages
|
||||
? null
|
||||
: await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
let streamFinished = false;
|
||||
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
|
||||
try {
|
||||
|
|
@ -271,7 +347,7 @@ export class QqHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
text,
|
||||
...(hasImages ? { content } : { text }),
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
@ -316,11 +392,15 @@ export class QqHarnessBridge {
|
|||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
} catch (error) {
|
||||
stream?.cancel?.();
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
||||
try {
|
||||
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
||||
await this.#bot.sendText(
|
||||
target,
|
||||
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
|
||||
);
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
|
||||
|
|
@ -345,7 +425,7 @@ export class QqHarnessBridge {
|
|||
|
||||
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
||||
const text = nonEmptyString(safeText(message));
|
||||
if (!text) {
|
||||
if (!text || hasQqImageAttachments(message)) {
|
||||
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue