dsh-im-ops/src/channels/qq/qq-bridge.mjs
2026-08-24 00:48:21 +08:00

1064 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import {
isControlCommand,
runControlCommand,
} from '../shared/control-command.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
validHarnessQuestion,
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import {
isModelCommand,
runModelCommand,
} from '../shared/model-command.mjs';
import {
isPresetCommand,
runPresetCommand,
} from '../shared/preset-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
import {
fetchImageBuffer,
hasInboundImages,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
trackOutboundArtifactProviderPromise,
} from '../shared/semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import { sendMarkdownReply } from './markdown-reply.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
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 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
'/models 按序号列出所有可用模型',
'/model [序号或完整模型ID] 查看或切换当前会话模型',
'示例:先发 /models,再发 /model 2',
'/presetlist 按序号列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',
'/preset --default 跟随 Host 默认',
'/stop 停止当前任务',
'/steer 补充指令 纠偏当前任务',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
function conversationKey(message) {
return `${message.kind}:${message.kind === 'group' ? message.groupOpenid : message.senderId}`;
}
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);
}
function hasQqFileAttachments(message) {
return Array.isArray(message?.attachments)
&& message.attachments.some((attachment) => !isQqImageAttachment(attachment));
}
async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
const response = await fetchImpl(new URL(normalizedUrl), {
method: 'GET',
signal,
redirect: 'follow',
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new Error(`QQ file download failed with HTTP ${response?.status ?? 'unknown'}`);
}
return Buffer.from(await response.arrayBuffer());
}
/** 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 = [];
const files = [];
for (const attachment of message?.attachments ?? []) {
const url = nonEmptyString(attachment?.url);
const name = nonEmptyString(attachment?.filename) ?? undefined;
const mediaType = attachmentMediaType(attachment);
const declaredSize = Number(attachment?.size);
if (isQqImageAttachment(attachment)) {
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,
});
},
});
continue;
}
files.push({
name: name ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
...(mediaType?.includes('/') ? { mediaType } : {}),
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
load: ({ signal } = {}) => {
if (!url) throw new Error('QQ file attachment has no download URL');
return fetchQqFileBuffer(url, { fetchImpl, signal });
},
});
}
return { content: safeText(message), images, files };
}
function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function artifactFailureText(fileName, error) {
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
if (error?.name === 'UploadDailyLimitExceededError') {
return `结果文件「${name}」已生成,但 QQ 今日文件上传额度已用完,请稍后重试。`;
}
switch (error?.code) {
case 'artifact-delivery-uncertain':
return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
case 'artifact-permission-required':
return `结果文件「${name}」已生成,但当前 QQ 机器人没有文件消息权限。`;
case 'artifact-too-large':
return `结果文件「${name}」超过当前 QQ 机器人可发送的文件大小,未发送。`;
case 'artifact-empty':
return `结果文件「${name}」为空,QQ 不允许发送空文件。`;
case 'artifact-changed':
case 'artifact-invalid':
case 'artifact-unavailable':
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
case 'artifact-rate-limited':
return `结果文件「${name}」暂时被 QQ 限流,未能发送,请稍后重试。`;
case 'artifact-provider-rejected':
return `结果文件「${name}」已生成,但 QQ 拒绝了该文件或文件消息。`;
default:
return `结果文件「${name}」已生成,但暂时未能通过 QQ 发送,请稍后重试。`;
}
}
function answerTextForDelivery(answer, artifacts) {
if (typeof answer === 'string' && answer.trim()) return answer;
return artifacts.length > 0 ? '结果文件已生成。' : answer;
}
function qqArtifactError(error, { dispatched = false } = {}) {
if (error?.code?.startsWith?.('artifact-') || error?.name === 'UploadDailyLimitExceededError') {
return error;
}
const status = Number(error?.httpStatus);
const wrapped = new Error('QQ file delivery failed', { cause: error });
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
else if (status === 413) wrapped.code = 'artifact-too-large';
else if (status === 429) wrapped.code = 'artifact-rate-limited';
else if (status === 400 || status === 404) {
wrapped.code = 'artifact-provider-rejected';
} else {
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
}
return wrapped;
}
function abortReason(signal) {
return signal?.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError');
}
function waitWithSignal(promise, signal) {
if (!signal) return promise;
signal.throwIfAborted();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
signal.removeEventListener('abort', onAbort);
callback(value);
};
const onAbort = () => finish(reject, abortReason(signal));
signal.addEventListener('abort', onAbort, { once: true });
Promise.resolve(promise).then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
if (signal.aborted) onAbort();
});
}
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)
&& !hasQqFileAttachments(message)
&& nonEmptyString(safeText(message));
}
export function createQqBridgeStatus() {
return {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
artifactsSent: 0,
artifactSendErrors: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
}
export class QqHarnessBridge {
#bot;
#ownerUserOpenid;
#harness;
#state;
#status;
#logger;
#replyTimeoutMs;
#signal;
#fetchImpl;
#fileUploadTimeoutMs;
#queues = new Map();
#pendingInteractions = new Map();
#interactionKeys = new Map();
#acceptedMessageIds = new Set();
#approvalTasks = new Set();
#commandTasks = new Set();
#approvals;
constructor({
bot,
ownerUserOpenid,
harness,
state,
status = createQqBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
signal,
fetchImpl = fetch,
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
}) {
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');
if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
throw new TypeError('fileUploadTimeoutMs must be a positive integer');
}
this.#bot = bot;
this.#ownerUserOpenid = ownerUserOpenid;
this.#harness = harness;
this.#state = state;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#signal = signal;
this.#fetchImpl = fetchImpl;
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
}
get status() {
return structuredClone(this.#status);
}
accept(message) {
if (this.#signal?.aborted) return Promise.resolve();
const messageId = nonEmptyString(message?.messageId);
const sender = nonEmptyString(message?.senderId);
if (!messageId || !sender || message?.senderIsBot === true
|| !['c2c', 'group'].includes(message?.kind)
|| this.#state.hasSeen(messageId)
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
const key = conversationKey(message);
this.#acceptedMessageIds.add(messageId);
if (message.kind === 'c2c'
&& (this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid)
&& message.replyTarget?.scope === 'c2c'
&& nonEmptyString(message.replyTarget.targetId) === sender) {
rememberConnectionTestTarget(this.#state, message.replyTarget);
}
const pending = this.#pendingInteractions.get(key);
const commandText = safeText(message);
const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
if (commandRunner && allowed && addressed) {
let task;
task = this.#processFastCommand(
message,
messageId,
key,
commandText,
commandRunner,
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to process a command:', error);
return this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。')
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
});
this.#commandTasks.add(task);
return task;
}
const approval = this.#approvals.claimReply({
key,
actor: sender,
messageId,
text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
? pending.queue
: null,
isQuestionPending: () => this.#pendingInteractions.has(key),
send: (text) => this.#bot.sendText(message.replyTarget, text),
});
if (approval) {
let task;
task = approval.process(async () => {
if (this.#state.hasSeen(messageId)) return false;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
return true;
})
.finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#approvalTasks.delete(task);
});
this.#approvalTasks.add(task);
return task;
}
if (pending && sender !== pending.actor) {
return this.#enqueueMessage(message, messageId, key);
}
if (pending?.submitting || pending?.claimedReplyMessageId) {
return this.#enqueueMessage(message, messageId, key);
}
if (pending) {
if (canClaimInteractionReply(message, pending)) {
pending.claimedReplyMessageId = messageId;
}
const previous = pending.queue ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#processInteractionReply(message, messageId, key, pending))
.catch((error) => this.#handleInteractionFailure(message, messageId, error))
.finally(() => {
this.#acceptedMessageIds.delete(messageId);
if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
if (pending.queue === current) pending.queue = null;
});
pending.queue = current;
return current;
}
return this.#enqueueMessage(message, messageId, key);
}
#enqueueMessage(message, messageId, key, {
releaseMessageId = true,
alreadyRecorded = false,
} = {}) {
const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const preparedMessage = allowed && addressed
? prefetchInboundFiles(
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
{ signal: this.#signal },
)
: undefined;
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
.finally(() => {
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key);
});
this.#queues.set(key, current);
return current;
}
async waitForIdle() {
await Promise.allSettled([
...this.#queues.values(),
...[...this.#pendingInteractions.values()].flatMap((pending) => (
pending.queue ? [pending.queue] : []
)),
...this.#approvalTasks,
...this.#commandTasks,
]);
}
async #processFastCommand(message, messageId, key, text, runner) {
this.#signal?.throwIfAborted();
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal,
hasImages: hasQqImageAttachments(message),
hasFiles: hasQqFileAttachments(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
});
if (result?.stopped) {
await Promise.allSettled([
this.#cancelPendingInteraction(key),
this.#approvals.closeRoute(key),
]);
}
for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#bot.sendText(message.replyTarget, reply);
}
this.#status.lastError = null;
}
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
if (artifacts.length === 0) {
return { receipt: baseReceipt, failureNoticeVisible: false };
}
const receipts = baseReceipt ? [baseReceipt] : [];
let failureNoticeVisible = false;
for (const artifact of artifacts) {
this.#signal?.throwIfAborted();
try {
if (typeof this.#bot.sendFile !== 'function') {
const unavailable = new Error('QQ file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const file = await materializeOutboundArtifact(artifact, {
signal: this.#signal,
});
this.#signal?.throwIfAborted();
let result;
try {
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
const pending = this.#bot.sendFile(
target,
{ buffer: file.bytes },
{
fileName: file.fileName,
onProgress: () => this.#signal?.throwIfAborted(),
},
);
trackOutboundArtifactProviderPromise(file, pending);
result = await waitWithSignal(pending, waitSignal);
} catch (error) {
if (this.#signal?.aborted) throw abortReason(this.#signal);
throw qqArtifactError(error, { dispatched: true });
}
this.#signal?.throwIfAborted();
const messageId = nonEmptyString(result?.message?.id);
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: 'qq-file',
providerMessageIds: messageId ? [messageId] : [],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
} catch (rawError) {
if (this.#signal?.aborted) throw rawError;
const error = qqArtifactError(rawError);
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
this.#logger.warn?.(
`[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
);
let providerMessageIds = [];
try {
const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
failureNoticeVisible = true;
providerMessageIds = providerMessageIdsFor(notice);
} catch (noticeError) {
if (this.#signal?.aborted) throw noticeError;
this.#logger.warn?.('[dsh-im:qq] unable to send the safe result-file failure notice');
}
receipts.push(createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds,
}));
} finally {
releaseOutboundArtifact(artifact);
}
}
return {
receipt: mergeDeliveryReceipts({
deliveryId: replyTo,
presentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
receipts,
}),
failureNoticeVisible,
};
}
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
if (this.#signal?.aborted) return;
const messageId = nonEmptyString(message?.messageId);
const sender = nonEmptyString(message?.senderId);
if (!messageId || !sender || message.senderIsBot === true) return;
if (!['c2c', 'group'].includes(message.kind)) return;
if (!alreadyRecorded) {
if (this.#state.hasSeen(messageId)) return;
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
return;
}
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const target = message.replyTarget;
const promptMessage = preparedMessage
?? qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
let stream = null;
try {
if (!text && !hasImages && !hasFiles) {
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
await this.#state.markSeen(messageId);
return;
}
const command = text.toLowerCase();
if (!hasImages && !hasFiles && command === '/help') {
await this.#bot.sendText(target, HELP_TEXT);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
await this.#state.markSeen(messageId);
return;
}
const workspaceCommand = hasImages || hasFiles
? null
: await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) {
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#bot.sendText(target, reply);
}
await this.#state.markSeen(messageId);
return;
}
const compactCommand = hasImages || hasFiles
? 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;
}
const content = hasImages
? await promptContentForMessage(promptMessage, { signal: this.#signal })
: undefined;
// QQ C2C keeps one stream bubble. Progress is collected but never submitted:
// some clients reject replacing an already visible stream frame, which would
// otherwise leave a stale progress bubble plus a separate fallback answer.
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
try {
stream = this.#bot.openStream({ target });
} catch (error) {
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
}
}
const toolErrors = [];
let answer;
let artifacts = [];
try {
({ answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
...(hasImages ? { content } : { text }),
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
control: { owner: this, key },
progressMode: 'all',
onUpdate: (update) => {
if (update.error) {
const label = nonEmptyString(update.toolName)
? `Tool call ${update.toolName}` : 'Tool call';
const text = `${label}\nError: ${update.error}`;
toolErrors.push(text);
}
},
onInteraction: (interaction) => this.#handleInteraction(interaction, {
key,
actor: sender,
target,
requiresMention: message.kind === 'group',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: promptMessage.files,
},
}));
} finally {
await Promise.allSettled([
this.#cancelPendingInteraction(key),
this.#approvals.closeRoute(key),
]);
}
this.#signal?.throwIfAborted();
const answerText = answerTextForDelivery(answer, artifacts);
const displayAnswer = toolErrors.length > 0
? `${answerText}\n\n---\n\n${toolErrors.join('\n\n')}`
: answerText;
let textReceipt = null;
let textSendError = null;
try {
let streamFinished = false;
if (stream) {
try {
await stream.update(displayAnswer);
streamFinished = true;
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'qq-text',
providerMessageIds: providerMessageIdsFor(stream),
});
try {
await stream.complete();
} catch (error) {
this.#logger.warn?.('[dsh-im:qq] QQ stream completion failed after visible final content:', error);
}
} catch (error) {
stream.cancel?.();
this.#logger.warn?.('[dsh-im:qq] QQ stream update failed; using markdown fallback:', error);
}
}
if (!streamFinished) {
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
logger: this.#logger,
});
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'qq-text',
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
});
}
} catch (error) {
textSendError = error;
this.#logger.warn?.('[dsh-im:qq] final text delivery failed; continuing with result files:', error);
}
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
const artifactDispatched = delivery.receipt?.artifacts?.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
return delivery.receipt;
} catch (error) {
if (error?.code === 'turn-stopped') {
try {
stream?.cancel?.();
} catch (streamError) {
this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
}
try {
await this.#bot.sendText(target, '已停止。');
} catch (sendError) {
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
}
await this.#state.markSeen(messageId);
return;
}
try {
stream?.cancel?.();
} catch (streamError) {
this.#logger.warn?.('[dsh-im:qq] unable to cancel a failed QQ stream:', streamError);
}
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,
inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? '消息处理失败,请稍后重试。',
);
await this.#state.markSeen(messageId);
} catch (sendError) {
this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
}
}
}
async #processInteractionReply(message, messageId, key, expected) {
this.#signal?.throwIfAborted();
const current = this.#pendingInteractions.get(key);
const claimed = expected.claimedReplyMessageId === messageId;
if (!current || current !== expected || current.submitting) {
if (claimed && (!current || current !== expected)) {
return this.#discardResolvedInteractionReply(message, messageId);
}
return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
}
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const text = nonEmptyString(safeText(message));
if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
return;
}
const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) {
if (claimed && (!pending || pending !== expected)) {
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT);
return;
}
return this.#enqueueMessage(message, messageId, key, {
releaseMessageId: false,
alreadyRecorded: true,
});
}
pending.target = message.replyTarget;
if (pending.needsPresentation) {
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = 'QQ 交互问题发送失败。';
this.#logger.error?.('[dsh-im:qq] failed to retry an interaction question');
pending.interaction.reconnect?.();
return;
}
const presentedPending = this.#pendingInteractions.get(key);
if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
if (claimed && (!presentedPending || presentedPending !== expected)) {
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT)
.catch(() => undefined);
return;
}
return this.#enqueueMessage(message, messageId, key, {
releaseMessageId: false,
alreadyRecorded: true,
});
}
}
const question = pending.questions[pending.index];
if (!question) return;
pending.answers.push(harnessAnswerForQuestion(question, text));
pending.index += 1;
if (pending.index < pending.questions.length) {
if (pending.claimedReplyMessageId === messageId) {
pending.claimedReplyMessageId = null;
}
pending.needsPresentation = true;
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = 'QQ 交互问题发送失败。';
this.#logger.error?.('[dsh-im:qq] failed to send the next interaction question');
pending.interaction.reconnect?.();
}
return;
}
pending.submitting = true;
try {
await pending.interaction.respond({
ok: true,
value: {
sessionId: pending.sessionId,
answer: { answers: pending.answers },
},
});
this.#clearPendingInteraction(key, pending.interactionId);
this.#status.lastError = null;
} catch (error) {
if (this.#signal?.aborted) return;
if (error?.code === 'interaction-not-pending') {
this.#clearPendingInteraction(key, pending.interactionId);
await this.#bot.sendText(pending.target, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
return;
}
if (this.#pendingInteractions.get(key) !== pending) return;
pending.submitting = false;
pending.answers.pop();
pending.index -= 1;
this.#status.lastError = '回答提交失败。';
this.#logger.error?.('[dsh-im:qq] failed to answer a Harness interaction');
await this.#bot.sendText(pending.target, '回答提交失败,请重新发送当前问题的答案。')
.catch(() => undefined);
}
}
async #handleInteraction(interaction, {
key,
actor,
target,
requiresMention,
}) {
if (interaction?.kind === 'approval') {
return this.#approvals.handleRequested(interaction, {
key,
actor,
requiresMention,
send: (text) => this.#bot.sendText(target, text),
});
}
if (interaction?.kind !== 'question') return;
const questions = interaction?.payload?.questions;
const interactionId = typeof interaction?.interactionId === 'string'
? interaction.interactionId
: interaction?.rpcId;
if (typeof interaction?.rpcId !== 'string'
|| typeof interactionId !== 'string'
|| typeof interaction.sessionId !== 'string'
|| !Array.isArray(questions)
|| questions.length === 0
|| questions.some((question) => !validHarnessQuestion(question))) {
this.#logger.warn?.('[dsh-im:qq] ignored an invalid Harness question interaction');
return;
}
if (interaction.recovered === true) {
await interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'QQ safely cancelled an interaction left by an earlier client.',
details: {},
},
});
await this.#bot.sendText(
target,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
).catch(() => undefined);
return;
}
const existing = this.#pendingInteractions.get(key);
if (existing?.interactionId === interactionId) {
existing.interaction = interaction;
if (existing.needsPresentation) await this.#presentInteraction(existing);
return;
}
if (this.#interactionKeys.has(interactionId)) return;
if (existing) {
await interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'QQ is already handling another user interaction.',
details: {},
},
});
return;
}
const pending = {
kind: 'question',
interactionId,
sessionId: interaction.sessionId,
interaction,
actor,
requiresMention,
questions,
answers: [],
index: 0,
target,
queue: null,
claimedReplyMessageId: null,
presentationPromise: null,
submitting: false,
needsPresentation: true,
};
this.#pendingInteractions.set(key, pending);
this.#interactionKeys.set(interactionId, key);
await this.#presentInteraction(pending);
}
async #handleInteractionResolved(resolution) {
if (resolution?.kind === 'approval') {
await this.#approvals.handleResolved(resolution);
return;
}
const interactionId = resolution?.interactionId;
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
const key = this.#interactionKeys.get(interactionId);
if (!key) return;
this.#clearPendingInteraction(key, interactionId);
}
#presentInteraction(pending) {
if (!pending.needsPresentation) return Promise.resolve();
if (pending.presentationPromise) return pending.presentationPromise;
const question = pending.questions[pending.index];
if (!question) return Promise.resolve();
const presentation = this.#bot.sendText(
pending.target,
harnessQuestionText(
question,
pending.index,
pending.questions.length,
{ requiresMention: pending.requiresMention },
),
).then(() => {
pending.needsPresentation = false;
}).finally(() => {
if (pending.presentationPromise === presentation) pending.presentationPromise = null;
});
pending.presentationPromise = presentation;
return presentation;
}
async #discardResolvedInteractionReply(message, messageId) {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
}
#takePendingInteraction(key, interactionId) {
const pending = this.#pendingInteractions.get(key);
if (!pending
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
this.#pendingInteractions.delete(key);
this.#interactionKeys.delete(pending.interactionId);
return pending;
}
#clearPendingInteraction(key, interactionId) {
return this.#takePendingInteraction(key, interactionId) !== null;
}
async #cancelPendingInteraction(key) {
const pending = this.#takePendingInteraction(key);
if (!pending || pending.kind !== 'question') return;
try {
await pending.interaction.respond({
ok: false,
error: {
code: 'cancelled',
message: 'The QQ interaction ended before the user answered.',
details: {},
},
}, { signal: AbortSignal.timeout(5_000) });
} catch (error) {
if (error?.code !== 'interaction-not-pending') {
this.#logger.warn?.('[dsh-im:qq] failed to cancel a pending Harness interaction');
}
}
}
async #handleInteractionFailure(message, messageId, error) {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to process an interaction reply:', error);
if (!this.#state.hasSeen(messageId)) {
await this.#state.markSeen(messageId).catch(() => undefined);
}
await this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。')
.catch(() => undefined);
}
}