feat: add structured message failure reporting

This commit is contained in:
xmanrui 2026-08-25 13:42:43 +08:00
parent b42fc411c2
commit ad3c08ace3
59 changed files with 2405 additions and 607 deletions

View file

@ -830,7 +830,7 @@ export function createDingtalkApi({
clientId: appKey,
clientSecret: appSecret,
cardInstanceId,
text: t('消息处理失败,请稍后重试。'),
text: t('卡片已结束,请查看后续消息。'),
signal: cleanupSignal,
}).catch(() => undefined);
}

View file

@ -32,6 +32,7 @@ import {
} from '../shared/batch-input.mjs';
import {
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
@ -46,10 +47,15 @@ import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import { t } from '../shared/i18n.mjs';
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const HELP_TEXT_LINES = [
@ -326,6 +332,7 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
lastMessageError: null,
pendingSenders: structuredClone(pendingSenders),
stats: {
messagesReceived: 0,
@ -484,9 +491,13 @@ export class DingtalkHarnessBridge {
commandRunner,
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = t('钉钉命令处理失败。');
this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
return this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-dingtalk] failed to process a command [${failure.referenceId}]`,
safeErrorDiagnostic(error),
);
return this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
@ -659,9 +670,13 @@ export class DingtalkHarnessBridge {
this.#status.lastError = null;
}).catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = t('钉钉命令处理失败。');
this.#logger.error?.('[dsh-dingtalk] failed to process a batch input message', safeErrorDiagnostic(error));
await this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-dingtalk] failed to process a batch input message [${failure.referenceId}]`,
safeErrorDiagnostic(error),
);
await this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
@ -819,7 +834,7 @@ export class DingtalkHarnessBridge {
});
}
} catch (error) {
textDeliveryError = error;
textDeliveryError = channelDeliveryFailure(error);
}
const delivery = await this.#deliverArtifacts(
fileTarget(message, sender, this.#clientId),
@ -829,9 +844,15 @@ export class DingtalkHarnessBridge {
textReceipt,
);
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
if (textDeliveryError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textDeliveryError);
}
increment(this.#status, 'messagesReplied');
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
return delivery.receipt;
} catch (error) {
let batchFailureMessage = null;
@ -848,15 +869,19 @@ export class DingtalkHarnessBridge {
return;
}
if (this.#signal?.aborted) return;
this.#status.lastError = t('钉钉消息处理失败。');
this.#status.lastError = error?.message ?? String(error);
const userMessage = inboundFileUserMessage(error)
?? dingtalkImageErrorUserMessage(error);
const failure = setLastMessageFailure(this.#status, error, {
userMessage,
reason: imagePromptDiagnostic(error)?.reason,
});
this.#logger.error?.(
'[dsh-dingtalk] failed to process an inbound message',
`[dsh-dingtalk] failed to process an inbound message [${failure.referenceId}]`,
safeErrorDiagnostic(error),
);
try {
const errorText = inboundFileUserMessage(error)
?? dingtalkImageErrorUserMessage(error)
?? t(CARD_ERROR_TEXT);
const errorText = messageFailureText(failure);
const visibleError = batchFailureMessage
? `${errorText}\n\n${batchFailureMessage}`
: errorText;
@ -1201,9 +1226,13 @@ export class DingtalkHarnessBridge {
sendFile: typeof this.#api.sendFile === 'function'
? (file) => sendArtifact('sendFile', file)
: undefined,
sendFailureNotice: (artifact, error) => this.#send(
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error),
reason: error?.code,
}),
sendFailureNotice: (_artifact, _error, failure) => this.#send(
sessionWebhook,
artifactFailureText(artifact?.fileName, error),
messageFailureText(failure),
),
logger: this.#logger,
});
@ -1211,7 +1240,11 @@ export class DingtalkHarnessBridge {
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
return {
receipt: delivery.receipt,
userVisible: delivery.userVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
}

View file

@ -1,7 +1,7 @@
import { t } from '../shared/i18n.mjs';
const DEFAULT_UPDATE_INTERVAL_MS = 500;
const FAILURE_TEXT = '消息处理失败,请稍后重试。';
const CLOSED_TEXT = '卡片已结束,请查看后续消息。';
function requiredText(value, name) {
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
@ -102,7 +102,7 @@ export function createDingTalkCardStream({
if (!cleanupPromise) {
cleanupPromise = api.failAiCard({
...cardRequest,
text: t(FAILURE_TEXT),
text: t(CLOSED_TEXT),
signal: AbortSignal.timeout(5_000),
}).then(
() => true,

View file

@ -11,6 +11,7 @@ import {
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { publicMessageFailure } from '../shared/message-failure.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@ -516,6 +517,7 @@ export class DingtalkController {
messagesReceived: Number(currentStatus.messagesReceived) || 0,
messagesReplied: Number(currentStatus.messagesReplied) || 0,
},
lastMessageError: publicMessageFailure(currentStatus.lastMessageError),
senders: {
pending,
approved: config.approvedSenders.map(publicApprovedSender),

View file

@ -9,6 +9,7 @@ import {
} from './message-utils.mjs';
import {
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
@ -48,6 +49,12 @@ import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.m
import {
createDeliveryReceipt,
} from '../shared/semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import {
MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL,
@ -365,6 +372,7 @@ function ensureStatus(status) {
status.lastReplyAt ??= null;
status.lastRejectedAt ??= null;
status.lastError ??= null;
status.lastMessageError ??= null;
}
export class FeishuHarnessBridge {
@ -801,6 +809,30 @@ export class FeishuHarnessBridge {
return current;
}
#recordFailure(error, {
logLabel = 'operation',
logLevel = 'warn',
userMessage,
reason,
} = {}) {
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error, { userMessage, reason });
this.#logger?.[logLevel]?.(
`[dsh-feishu] ${logLabel} failed [${failure.referenceId}]:`,
error?.message ?? String(error),
);
return failure;
}
async #sendFailure(chatId, error, options = {}) {
const failure = this.#recordFailure(error, options);
const text = options.appendMessage
? `${messageFailureText(failure)}\n\n${options.appendMessage}`
: messageFailureText(failure);
await this.#send(chatId, text).catch(() => undefined);
return failure;
}
async #handleMessageFailure(event, messageId, processingReaction, error) {
if (error?.code === 'turn-stopped') {
await this.#removeProcessingReaction(messageId, processingReaction);
@ -813,15 +845,21 @@ export class FeishuHarnessBridge {
await this.#removeProcessingReaction(messageId, processingReaction);
return;
}
this.#logger.error?.('[dsh-feishu] message handling failed:', error?.message ?? String(error));
this.#status.lastError = error?.message ?? String(error);
const userMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error);
const failure = this.#recordFailure(error, {
logLabel: 'message handling',
logLevel: 'error',
userMessage,
reason: imagePromptDiagnostic(error)?.reason,
});
await this.#finishReaction(messageId, processingReaction, 'ERROR');
const failureText = error?.batchInputMessage
? `${messageFailureText(failure)}\n\n${error.batchInputMessage}`
: messageFailureText(failure);
await this.#send(
event.message.chat_id,
error?.batchInputMessage
?? inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
failureText,
).catch(() => undefined);
}
@ -1025,7 +1063,11 @@ export class FeishuHarnessBridge {
const batchSubmission = event.batchSubmission ?? null;
let batchAskCompleted = false;
try {
const receipt = await this.#answerWithStream(event, key, message, {
const {
receipt,
artifactSendErrors,
textDeliveryErrors = 0,
} = await this.#answerWithStream(event, key, message, {
onAskComplete: batchSubmission
? () => {
batchAskCompleted = this.#batchInputs.complete(
@ -1038,6 +1080,9 @@ export class FeishuHarnessBridge {
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
if (textDeliveryErrors === 0 && artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
return receipt;
} catch (error) {
if (batchSubmission && !batchAskCompleted) {
@ -1049,6 +1094,9 @@ export class FeishuHarnessBridge {
if (failed.retained) {
const batchError = new Error(error?.message ?? String(error), { cause: error });
batchError.code = error?.code;
batchError.providerCode = error?.providerCode;
batchError.method = error?.method;
if (Number.isInteger(error?.status)) batchError.status = error.status;
batchError.batchInputMessage = failed.message;
throw batchError;
}
@ -1643,9 +1691,7 @@ export class FeishuHarnessBridge {
})
.catch(async (error) => {
if (this.#signal?.aborted) return;
this.#logger.warn?.('[dsh-feishu] card action failed:', error?.message ?? String(error));
this.#status.lastError = error?.message ?? String(error);
await this.#send(entry.chatId, t('卡片操作失败,请稍后重试。')).catch(() => undefined);
await this.#sendFailure(entry.chatId, error, { logLabel: 'card action' });
})
.finally(() => {
if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
@ -2008,8 +2054,7 @@ export class FeishuHarnessBridge {
},
);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
await this.#send(chatId, t('暂时无法获取会话列表,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'session list' });
}
}
@ -2026,8 +2071,7 @@ export class FeishuHarnessBridge {
{ key, updateMessageId },
);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
await this.#send(chatId, t('暂时无法获取工作区列表,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
}
}
@ -2038,7 +2082,10 @@ export class FeishuHarnessBridge {
await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
await this.#sendMenuCard(key, chatId, { updateMessageId });
} catch (error) {
await this.#send(chatId, t('绑定失败:{message}', { message: safeErrorText(error) }));
await this.#sendFailure(chatId, error, {
logLabel: 'session binding',
userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }),
});
}
}
@ -2048,7 +2095,10 @@ export class FeishuHarnessBridge {
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
await this.#sendMenuCard(key, chatId, { updateMessageId });
} catch (error) {
await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
await this.#sendFailure(chatId, error, {
logLabel: 'workspace switch',
userMessage: t('切换失败:{message}', { message: safeErrorText(error) }),
});
}
}
@ -2247,8 +2297,7 @@ export class FeishuHarnessBridge {
catalog._currentId = settings.agentPreset;
await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] preset card failed:', error.message);
await this.#send(chatId, t('暂时无法获取预设列表,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'preset card' });
}
}
@ -2273,8 +2322,7 @@ export class FeishuHarnessBridge {
}
await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] model card failed:', error.message);
await this.#send(chatId, t('暂时无法获取模型列表,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'model card' });
}
}
@ -2302,8 +2350,7 @@ export class FeishuHarnessBridge {
}
await this.#send(chatId, lines.join('\n'));
} catch (error) {
this.#logger.warn?.('[dsh-feishu] status text failed:', error.message);
await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'status text' });
}
}
@ -2355,8 +2402,7 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] status card failed:', error.message);
await this.#send(chatId, t('暂时无法获取系统状态,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'status card' });
}
}
@ -2381,8 +2427,7 @@ export class FeishuHarnessBridge {
);
await this.#send(chatId, result?.message || t('上下文压缩失败。'));
} catch (error) {
this.#logger.warn?.('[dsh-feishu] compact failed:', error.message, error.code);
await this.#send(chatId, t('上下文压缩失败,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'compact' });
}
}
@ -2405,8 +2450,7 @@ export class FeishuHarnessBridge {
}
await this.#send(chatId, result?.message || t('/stop 执行完成。'));
} catch (error) {
this.#logger.warn?.('[dsh-feishu] stop failed:', error.message);
await this.#send(chatId, t('停止任务失败,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'stop' });
}
}
@ -2446,8 +2490,7 @@ export class FeishuHarnessBridge {
if (reply) await this.#send(chatId, reply);
}
} catch (error) {
this.#logger.warn?.('[dsh-feishu] preset default failed:', error.message);
await this.#send(chatId, t('预设重置失败,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'preset reset' });
return;
}
try {
@ -2470,8 +2513,7 @@ export class FeishuHarnessBridge {
if (reply) await this.#send(chatId, reply);
}
} catch (error) {
this.#logger.warn?.('[dsh-feishu] preset select failed:', error.message);
await this.#send(chatId, t('预设切换失败,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'preset selection' });
return;
}
try {
@ -2503,8 +2545,7 @@ export class FeishuHarnessBridge {
if (reply) await this.#send(chatId, reply);
}
} catch (error) {
this.#logger.warn?.('[dsh-feishu] model select failed:', error.message);
await this.#send(chatId, t('模型切换失败,请稍后重试。'));
await this.#sendFailure(chatId, error, { logLabel: 'model selection' });
return;
}
try {
@ -3046,10 +3087,14 @@ export class FeishuHarnessBridge {
signal: this.#signal,
})
: undefined,
sendFailureNotice: async (artifact, error) => ({
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error),
reason: error?.code,
}),
sendFailureNotice: async (_artifact, _error, failure) => ({
messageId: await this.#send(
chatId,
artifactFailureText(artifact?.fileName, error),
messageFailureText(failure),
),
}),
logger: this.#logger,
@ -3065,11 +3110,13 @@ export class FeishuHarnessBridge {
presentation: 'feishu-files',
}),
failureNoticeVisible: delivery.failureNoticeVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
return {
receipt: delivery.receipt,
failureNoticeVisible: delivery.failureNoticeVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
@ -3110,7 +3157,7 @@ export class FeishuHarnessBridge {
},
);
} catch (error) {
textSendError = error;
textSendError = channelDeliveryFailure(error);
this.#logger.warn?.(
'[dsh-feishu] final text delivery failed; continuing with result files:',
error,
@ -3123,8 +3170,11 @@ export class FeishuHarnessBridge {
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
if (textSendError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textSendError);
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return delivery.receipt;
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
}
let promptStarted = false;
@ -3177,7 +3227,7 @@ export class FeishuHarnessBridge {
},
);
} catch (fallbackError) {
textSendError = fallbackError;
textSendError = channelDeliveryFailure(fallbackError);
this.#logger.warn?.(
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
fallbackError,
@ -3195,8 +3245,11 @@ export class FeishuHarnessBridge {
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
if (textSendError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textSendError);
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return delivery.receipt;
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
}
if (promptStarted) throw error;
@ -3224,7 +3277,7 @@ export class FeishuHarnessBridge {
},
);
} catch (fallbackError) {
textSendError = fallbackError;
textSendError = channelDeliveryFailure(fallbackError);
this.#logger.warn?.(
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
fallbackError,
@ -3237,8 +3290,11 @@ export class FeishuHarnessBridge {
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
if (textSendError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textSendError);
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return delivery.receipt;
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
}
const delivery = await this.#deliverArtifacts(
chatId,
@ -3251,7 +3307,7 @@ export class FeishuHarnessBridge {
}),
);
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
return delivery.receipt;
return delivery;
}
async #processInteractionReply(event, messageId, key, expected, processingReaction) {

View file

@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { connectionTestMessage } from '../shared/connection-test.mjs';
import { publicMessageFailure } from '../shared/message-failure.mjs';
import { RegistrationManager } from './registration-manager.mjs';
import {
CALLBACK_REPAIR_OPERATION,
@ -633,6 +634,7 @@ export class MultiBotDshFeishuController {
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
bot: publicBot(config),
connection,
lastMessageError: publicMessageFailure(connection.lastMessageError),
error,
};
});

View file

@ -29,6 +29,7 @@ import {
import {
fetchImageBuffer,
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
@ -45,6 +46,12 @@ import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import { sendMarkdownReply } from './markdown-reply.mjs';
import { t } from '../shared/i18n.mjs';
@ -216,9 +223,15 @@ function answerTextForDelivery(answer, artifacts) {
}
function qqArtifactError(error, { dispatched = false } = {}) {
if (error?.code?.startsWith?.('artifact-') || error?.name === 'UploadDailyLimitExceededError') {
if (error?.code?.startsWith?.('artifact-')) {
return error;
}
if (error?.name === 'UploadDailyLimitExceededError') {
const wrapped = new Error('QQ daily file upload limit exceeded', { cause: error });
wrapped.name = error.name;
wrapped.code = 'artifact-rate-limited';
return wrapped;
}
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';
@ -341,6 +354,7 @@ export function createQqBridgeStatus() {
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
lastMessageError: null,
};
}
@ -470,8 +484,12 @@ export class QqHarnessBridge {
).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, t('消息处理失败,请稍后重试。'))
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:qq] failed to process a command [${failure.referenceId}]:`,
error,
);
return this.#bot.sendText(message.replyTarget, messageFailureText(failure))
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -614,8 +632,12 @@ export class QqHarnessBridge {
}).catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to process a batch input message:', error);
await this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:qq] failed to process a batch input message [${failure.referenceId}]:`,
error,
);
await this.#bot.sendText(message.replyTarget, messageFailureText(failure))
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -627,7 +649,7 @@ export class QqHarnessBridge {
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
if (artifacts.length === 0) {
return { receipt: baseReceipt, failureNoticeVisible: false };
return { receipt: baseReceipt, failureNoticeVisible: false, artifactSendErrors: 0 };
}
const delivery = await deliverOutboundArtifacts({
artifacts,
@ -645,9 +667,13 @@ export class QqHarnessBridge {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
sendFailureNotice: (artifact, error) => this.#bot.sendText(
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error),
reason: error?.code,
}),
sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error),
messageFailureText(failure),
),
logger: this.#logger,
});
@ -658,6 +684,7 @@ export class QqHarnessBridge {
return {
receipt: delivery.receipt,
failureNoticeVisible: delivery.failureNoticeVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
@ -676,6 +703,12 @@ export class QqHarnessBridge {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
let messageRecorded = alreadyRecorded;
const markMessageSeen = async () => {
if (messageRecorded) return;
await this.#state.markSeen(messageId);
messageRecorded = true;
};
if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
@ -694,25 +727,25 @@ export class QqHarnessBridge {
try {
if (!text && !hasImages && !hasFiles) {
await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
await this.#state.markSeen(messageId);
await markMessageSeen();
return;
}
const command = text.toLowerCase();
if (!hasImages && !hasFiles && command === '/help') {
await this.#bot.sendText(target, helpText());
await this.#state.markSeen(messageId);
await markMessageSeen();
return;
}
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#bot.sendText(target, t('QQ 机器人与 DeepSeek Harness 连接正常。'));
await this.#state.markSeen(messageId);
await markMessageSeen();
return;
}
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#bot.sendText(target, t('已开启新会话。请发送你的问题。'));
await this.#state.markSeen(messageId);
await markMessageSeen();
return;
}
const workspaceCommand = hasImages || hasFiles
@ -722,7 +755,7 @@ export class QqHarnessBridge {
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#bot.sendText(target, reply);
}
await this.#state.markSeen(messageId);
await markMessageSeen();
return;
}
const compactCommand = hasImages || hasFiles
@ -736,7 +769,7 @@ export class QqHarnessBridge {
);
if (compactCommand) {
await this.#bot.sendText(target, compactCommand.message);
await this.#state.markSeen(messageId);
await markMessageSeen();
return;
}
@ -757,6 +790,9 @@ export class QqHarnessBridge {
let answer;
let artifacts = [];
try {
// Persist consumption before handing the prompt to Harness. Provider
// redelivery after a failed error notice must never execute it twice.
await markMessageSeen();
({ answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
@ -771,10 +807,13 @@ export class QqHarnessBridge {
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);
const name = (nonEmptyString(update.toolName) ?? t('工具'))
.replace(/[\r\n]+/gu, ' ')
.slice(0, 80);
toolErrors.push(t(
'工具调用「{name}」未成功,请检查工具配置或稍后重试。',
{ name },
));
}
},
onInteraction: (interaction) => this.#handleInteraction(interaction, {
@ -836,7 +875,7 @@ export class QqHarnessBridge {
});
}
} catch (error) {
textSendError = error;
textSendError = channelDeliveryFailure(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);
@ -846,10 +885,15 @@ export class QqHarnessBridge {
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
await this.#state.markSeen(messageId);
if (textSendError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textSendError);
}
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
if (!textSendError && delivery.artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
return delivery.receipt;
} catch (error) {
let batchFailureMessage = null;
@ -872,7 +916,7 @@ export class QqHarnessBridge {
} catch (sendError) {
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
}
await this.#state.markSeen(messageId);
await markMessageSeen();
return;
}
try {
@ -882,16 +926,23 @@ export class QqHarnessBridge {
}
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);
const userMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error);
const failure = setLastMessageFailure(this.#status, error, {
userMessage,
reason: imagePromptDiagnostic(error)?.reason,
});
this.#logger.error?.(
`[dsh-im:qq] failed to process an inbound message [${failure.referenceId}]:`,
error,
);
try {
const errorMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? t('消息处理失败,请稍后重试。');
const errorMessage = messageFailureText(failure);
await this.#bot.sendText(
target,
batchFailureMessage ? `${errorMessage}\n\n${batchFailureMessage}` : errorMessage,
);
await this.#state.markSeen(messageId);
await markMessageSeen();
} catch (sendError) {
this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
}
@ -1166,11 +1217,15 @@ export class QqHarnessBridge {
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);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:qq] failed to process an interaction reply [${failure.referenceId}]:`,
error,
);
if (!this.#state.hasSeen(messageId)) {
await this.#state.markSeen(messageId).catch(() => undefined);
}
await this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
await this.#bot.sendText(message.replyTarget, messageFailureText(failure))
.catch(() => undefined);
}
}

View file

@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { connectionTestMessage } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { publicMessageFailure } from '../shared/message-failure.mjs';
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
@ -319,6 +320,7 @@ export class QqController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});

View file

@ -532,6 +532,41 @@ export class HarnessInteractionError extends Error {
}
}
export class HarnessTurnError extends Error {
constructor(code, { reason, providerCode } = {}) {
super(`Harness turn failed (${code})`);
this.name = 'HarnessTurnError';
this.code = code;
this.promptAccepted = true;
if (reason && typeof reason === 'object') this.reason = reason;
if (typeof providerCode === 'string' && providerCode) this.providerCode = providerCode;
}
}
function harnessTurnError(reason) {
const kind = nonEmptyText(reason?.kind) ?? nonEmptyText(reason);
if (kind === 'error') {
const failure = reason?.error ?? reason?.failure;
return new HarnessTurnError('harness-turn-failed', {
reason,
providerCode: nonEmptyText(failure?.code) ?? undefined,
});
}
if (kind === 'max-tokens') return new HarnessTurnError('model-max-tokens', { reason });
if (kind === 'blocked') return new HarnessTurnError('turn-blocked', { reason });
if (['interrupted', 'stopped', 'cancelled', 'canceled'].includes(kind)) {
return new HarnessTurnError('turn-interrupted', { reason });
}
if (kind === 'aborted') return new HarnessTurnError('turn-aborted', { reason });
if (kind === 'completed') return new HarnessTurnError('model-empty-response', { reason });
return new HarnessTurnError('harness-turn-failed', { reason });
}
function harnessTurnSucceeded(reason) {
if (reason === null || reason === undefined) return true;
return (nonEmptyText(reason?.kind) ?? nonEmptyText(reason)) === 'completed';
}
export class HarnessClient {
#baseUrl;
#workspace;
@ -1282,6 +1317,9 @@ export class HarnessClient {
}
if (!tracker.finished) continue;
turnFinished = true;
if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
throw harnessTurnError(tracker.reason);
}
// An accepted /stop revokes attachment delivery even when Harness
// preserved a useful partial text answer for the existing UX.
const artifactCount = ownership?.stopRequested
@ -1292,11 +1330,9 @@ export class HarnessClient {
}
if (artifactCount > 0) return '';
if (ownership?.stopRequested) throw turnStoppedError();
throw new Error(
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
);
throw harnessTurnError(tracker.reason);
}
throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
throw new HarnessTurnError('harness-reply-timeout');
} catch (error) {
// Once cancellation was accepted, transport/poll failures and timeouts
// describe the convergence of that stop, not an unrelated ask failure.

View file

@ -27,6 +27,83 @@ export default {
'结果文件「{name}」已生成,但当前渠道暂时未能发送,请稍后重试。':
'The result file "{name}" was generated, but this channel could not send it right now. Please try again later.',
'消息处理失败,请稍后重试。': 'Failed to process the message. Please try again later.',
'卡片已结束,请查看后续消息。': 'This card has ended. Please check the next message.',
'工具调用「{name}」未成功,请检查工具配置或稍后重试。': 'Tool call "{name}" did not succeed. Check the tool configuration or try again later.',
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。':
'Could not connect to the processing service, so the message was not submitted. Make sure DeepSeek Harness is running, then try again.',
'处理服务响应超时,消息尚未开始处理。请稍后重试。':
'The processing service timed out before the message started. Please try again later.',
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。':
'The task status could not be confirmed and the task may have started. Wait or send /stop before submitting it again.',
'消息提交结果未能确认,任务可能已经开始。请先等待或发送 /status 查看状态,不要立即重复提交。':
'The message submission could not be confirmed and the task may have started. Wait or send /status before submitting it again.',
'暂时无法读取任务进度,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
'Task progress is temporarily unavailable and the task may still be running. Wait or send /stop before submitting it again.',
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。':
'The processing service rejected the bot connection. Ask an administrator to check the Harness address, proxy, or access settings.',
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。':
'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.',
'DeepSeek Harness 暂时无法完成请求,请稍后重试。':
'DeepSeek Harness could not complete the request. Please try again later.',
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.',
'模型凭据缺失或已失效。请管理员检查模型配置后重试。':
'The model credentials are missing or invalid. Ask an administrator to check the model settings.',
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。':
'The model quota or balance is insufficient. Ask an administrator to add quota or switch models, then try again.',
'模型服务正在限流,本次任务未完成。请稍后重试。':
'The model service is rate-limiting requests. Please try again later.',
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。':
'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
'当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。':
'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.',
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
'当前模型不支持所选配置。请切换模型或推理等级后重试。':
'The current model does not support the selected settings. Switch the model or reasoning effort, then try again.',
'模型服务响应超时,本次任务未完成。请稍后重试。':
'The model service timed out and the task did not finish. Please try again later.',
'暂时无法连接模型服务,本次任务未完成。请稍后重试。':
'The model service is temporarily unreachable and the task did not finish. Please try again later.',
'模型服务暂时异常,本次任务未完成。请稍后重试。':
'The model service is temporarily unavailable and the task did not finish. Please try again later.',
'模型回复中断或格式异常,本次任务未完成。请重试。':
'The model reply was interrupted or malformed, so the task did not finish. Please try again.',
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。':
'The model returned no displayable content. Try again, or switch models if this keeps happening.',
'模型拒绝处理当前内容。请调整问题内容后重试。':
'The model rejected the current content. Revise the request and try again.',
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
'任务被意外中断,本次未完成。请重试。':
'The task was unexpectedly interrupted and did not finish. Please try again.',
'当前会话已不存在。请发送 /new 创建新会话后重试。':
'The current Session no longer exists. Send /new to create one, then try again.',
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。':
'The current Session is still processing the previous task. Wait for it to finish, or send /stop before trying again.',
'工作区或会话状态刚刚发生变化。请重新发送这条消息。':
'The Workspace or Session state just changed. Please send this message again.',
'当前工作区不存在或暂不可用。请重新选择工作区后重试。':
'The current Workspace does not exist or is unavailable. Select another Workspace and try again.',
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。':
'The current Agent Preset does not exist or is unavailable. Send /presetlist and select another one.',
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。':
'The reply was generated, but the bot cannot send it. Ask an administrator to check channel permissions or reconnect the bot.',
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。':
'The reply was generated, but this channel is rate-limiting messages. Please try again later.',
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。':
'Reply delivery could not be confirmed. Check whether it already arrived before submitting the task again.',
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。':
'The reply was generated, but this channel could not send it. Please try again later.',
'当前消息包含无法处理的图片或文件。请调整后重新发送。':
'This message contains an image or file that cannot be processed. Adjust it and send it again.',
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
'错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
'{label}机器人': '{label} bot',
'目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
'目前支持文字、图片和文件消息。':

View file

@ -0,0 +1,244 @@
import { randomUUID } from 'node:crypto';
import { t } from './i18n.mjs';
const PROVIDER_FAILURES = Object.freeze({
AUTH: 'MODEL_AUTH',
MISSING_CREDENTIAL: 'MODEL_AUTH',
INVALID_CREDENTIAL: 'MODEL_AUTH',
QUOTA: 'MODEL_QUOTA',
RATE_LIMIT: 'MODEL_RATE_LIMIT',
CONTEXT_WINDOW_EXCEEDED: 'MODEL_CONTEXT_LIMIT',
UNKNOWN_MODEL: 'MODEL_UNAVAILABLE',
NO_ADAPTER: 'MODEL_UNAVAILABLE',
UNSUPPORTED_OPTION: 'MODEL_CONFIG',
UNSUPPORTED_REASONING_EFFORT: 'MODEL_CONFIG',
TIMEOUT: 'MODEL_TIMEOUT',
TRANSPORT: 'MODEL_TRANSPORT',
SERVER: 'MODEL_SERVICE',
STREAM_CLOSED: 'MODEL_STREAM',
MALFORMED_RESPONSE: 'MODEL_STREAM',
EMPTY_RESPONSE: 'MODEL_EMPTY_REPLY',
CONTENT_FILTER: 'MODEL_CONTENT_REJECTED',
UNSUPPORTED_CONTENT: 'MODEL_CONFIG',
});
const FAILURE_MESSAGES = Object.freeze({
HARNESS_CONNECT:
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
HARNESS_TIMEOUT:
'处理服务响应超时,消息尚未开始处理。请稍后重试。',
HARNESS_RESULT_UNCERTAIN:
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。',
HARNESS_ACCESS:
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。',
HARNESS_PROTOCOL:
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
HARNESS_SERVICE:
'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
MODEL_REPLY_TIMEOUT:
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
MODEL_AUTH:
'模型凭据缺失或已失效。请管理员检查模型配置后重试。',
MODEL_QUOTA:
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。',
MODEL_RATE_LIMIT:
'模型服务正在限流,本次任务未完成。请稍后重试。',
MODEL_CONTEXT_LIMIT:
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
MODEL_UNAVAILABLE:
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
MODEL_CONFIG:
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
MODEL_TIMEOUT:
'模型服务响应超时,本次任务未完成。请稍后重试。',
MODEL_TRANSPORT:
'暂时无法连接模型服务,本次任务未完成。请稍后重试。',
MODEL_SERVICE:
'模型服务暂时异常,本次任务未完成。请稍后重试。',
MODEL_STREAM:
'模型回复中断或格式异常,本次任务未完成。请重试。',
MODEL_EMPTY_REPLY:
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。',
MODEL_CONTENT_REJECTED:
'模型拒绝处理当前内容。请调整问题内容后重试。',
MODEL_OUTPUT_LIMIT:
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
TURN_BLOCKED:
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
TURN_INTERRUPTED:
'任务被意外中断,本次未完成。请重试。',
SESSION_NOT_FOUND:
'当前会话已不存在。请发送 /new 创建新会话后重试。',
SESSION_BUSY:
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
SESSION_STALE:
'工作区或会话状态刚刚发生变化。请重新发送这条消息。',
WORKSPACE_UNAVAILABLE:
'当前工作区不存在或暂不可用。请重新选择工作区后重试。',
PRESET_UNAVAILABLE:
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。',
CHANNEL_PERMISSION:
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。',
CHANNEL_RATE_LIMIT:
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。',
CHANNEL_DELIVERY_UNCERTAIN:
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。',
CHANNEL_DELIVERY:
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
INPUT_INVALID:
'当前消息包含无法处理的图片或文件。请调整后重新发送。',
INTERNAL_UNKNOWN:
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
});
function providerFailureCode(error) {
if (error?.code !== 'harness-turn-failed') return null;
const value = error?.providerCode ?? error?.details?.providerCode;
if (typeof value !== 'string') return null;
return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
}
function failureCode(error) {
const code = typeof error?.code === 'string' ? error.code : '';
const providerCode = providerFailureCode(error);
if (providerCode) return providerCode;
if (code === 'harness-connect-failed') {
return ['session.prompt', 'session.history'].includes(error?.method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_CONNECT';
}
if (code === 'harness-timeout') {
return ['session.prompt', 'session.history'].includes(error?.method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_TIMEOUT';
}
if (code === 'harness-reply-timeout') return 'MODEL_REPLY_TIMEOUT';
if ([
'harness-auth-required', 'harness-proxy-auth-required',
'harness-loopback-forbidden', 'harness-host-untrusted',
'harness-request-forbidden',
].includes(code)) return 'HARNESS_ACCESS';
if (['harness-api-not-found', 'harness-response-invalid'].includes(code)) {
return 'HARNESS_PROTOCOL';
}
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
if (code === 'turn-blocked') return 'TURN_BLOCKED';
if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
if (code === 'agent-busy') return 'SESSION_BUSY';
if (code === 'workspace-session-stale') return 'SESSION_STALE';
if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
if (code.startsWith('image-') || code.startsWith('inbound-file-')
|| code === 'attachment-error') return 'INPUT_INVALID';
const status = Number(error?.status ?? error?.httpStatus);
if (status === 401 || status === 403
|| [
'channel-permission',
'permission-required',
'artifact-permission-required',
'forbidden',
'stale-token',
].includes(code)) {
return 'CHANNEL_PERMISSION';
}
if (status === 429
|| ['channel-rate-limit', 'rate-limited', 'artifact-rate-limited'].includes(code)) {
return 'CHANNEL_RATE_LIMIT';
}
if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
.includes(code)) {
return 'CHANNEL_DELIVERY_UNCERTAIN';
}
if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
|| ['network-error', 'timeout'].includes(code)) {
return 'CHANNEL_DELIVERY';
}
return 'INTERNAL_UNKNOWN';
}
function safeReferenceId(value) {
return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
? value
: `MF-${randomUUID().slice(0, 8).toUpperCase()}`;
}
function safeFailureReason(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(value)) return null;
return value.toUpperCase().replaceAll('-', '_');
}
export function classifyMessageFailure(error, {
userMessage,
reason,
referenceId,
at = Date.now(),
} = {}) {
const safeReason = safeFailureReason(reason);
const classifiedCode = failureCode(error);
const code = classifiedCode === 'INTERNAL_UNKNOWN'
&& safeReason
&& typeof userMessage === 'string'
&& userMessage.trim()
? 'INPUT_INVALID'
: classifiedCode;
return Object.freeze({
code,
reason: safeReason ?? code,
message: typeof userMessage === 'string' && userMessage.trim()
? userMessage.trim()
: t(FAILURE_MESSAGES[code]),
referenceId: safeReferenceId(referenceId),
at: Number.isFinite(at) ? at : Date.now(),
});
}
export function messageFailureText(failure) {
return `${failure.message}\n\n${t('错误码:{code};参考号:{referenceId}', failure)}`;
}
export function setLastMessageFailure(status, error, options) {
const failure = classifyMessageFailure(error, options);
status.lastMessageError = failure;
return failure;
}
export function clearLastMessageFailure(status) {
status.lastMessageError = null;
}
export function channelDeliveryFailure(error, { uncertain = true } = {}) {
const wrapped = new Error('Channel message delivery failed', { cause: error });
const status = Number(error?.status ?? error?.httpStatus);
wrapped.code = status === 401 || status === 403
? 'channel-permission'
: status === 429
? 'channel-rate-limit'
: uncertain
? 'channel-delivery-uncertain'
: 'channel-delivery-failed';
if (Number.isInteger(status)) wrapped.status = status;
return wrapped;
}
export function publicMessageFailure(value) {
if (!value || typeof value !== 'object'
|| typeof value.code !== 'string' || !value.code
|| typeof value.reason !== 'string' || !value.reason
|| typeof value.message !== 'string' || !value.message
|| typeof value.referenceId !== 'string' || !value.referenceId
|| !Number.isFinite(value.at)) return null;
return {
code: value.code.slice(0, 64),
reason: value.reason.slice(0, 64),
message: value.message.slice(0, 500),
referenceId: value.referenceId.slice(0, 40),
at: value.at,
};
}

View file

@ -73,6 +73,7 @@ export async function deliverOutboundArtifacts({
sendFile,
sendImage,
sendFailureNotice,
onFailure,
logger,
}) {
const receipts = baseReceipt ? [baseReceipt] : [];
@ -107,14 +108,20 @@ export async function deliverOutboundArtifacts({
} catch (error) {
if (isAbort(error, signal)) throw error;
artifactSendErrors += 1;
const failure = typeof onFailure === 'function'
? await onFailure(artifact, error)
: null;
const reference = typeof failure?.referenceId === 'string'
? ` [${failure.referenceId}]`
: '';
logger?.warn?.(
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
`[dsh-im:${channelKey}] result artifact delivery failed${reference} (${error?.code ?? 'unknown'})`,
);
let messageIds = [];
if (typeof sendFailureNotice === 'function') {
try {
signal?.throwIfAborted();
const notice = await sendFailureNotice(artifact, error);
const notice = await sendFailureNotice(artifact, error, failure);
signal?.throwIfAborted();
messageIds = providerIds(notice);
failureNoticeVisible = true;

View file

@ -27,6 +27,7 @@ import {
} from './batch-input.mjs';
import {
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from './image-prompt.mjs';
@ -45,6 +46,12 @@ import {
createTextDeliveryBlock,
providerMessageIdsFor,
} from './semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from './message-failure.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
@ -106,6 +113,7 @@ export function createTextBridgeStatus() {
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
lastMessageError: null,
};
}
@ -320,8 +328,9 @@ export class TextHarnessBridge {
})().catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
error,
);
}).finally(() => {
@ -399,8 +408,12 @@ export class TextHarnessBridge {
} catch (error) {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。')).catch(() => undefined);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
error,
);
await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
}
}
@ -426,9 +439,13 @@ export class TextHarnessBridge {
sendFile: typeof this.#bot.sendFile === 'function'
? (file) => this.#bot.sendFile(target, file)
: undefined,
sendFailureNotice: (artifact, error) => this.#bot.sendText(
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error, this.#descriptor),
reason: error?.code,
}),
sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error, this.#descriptor),
messageFailureText(failure),
),
logger: this.#logger,
});
@ -436,7 +453,11 @@ export class TextHarnessBridge {
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
return {
receipt: delivery.receipt,
userVisible: delivery.userVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
async #process(message, messageId, senderId, conversationKey, {
@ -643,25 +664,37 @@ export class TextHarnessBridge {
reason: result?.reason,
});
} catch (error) {
textDeliveryError = error;
textDeliveryError = channelDeliveryFailure(error);
}
}
const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
if (textReceipt?.deliveryOutcome === 'failed') {
const reason = textReceipt.reason ?? 'text-delivery-failed';
textDeliveryError = new Error(`Final text delivery failed (${reason})`);
textDeliveryError.code = reason;
textDeliveryError = channelDeliveryFailure(
new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
{ uncertain: false },
);
} else if (finalDeliveryUnknown) {
textDeliveryError = channelDeliveryFailure(
new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
);
}
// A failed final text must not discard an already registered result file.
// Settle the independent attachment path before surfacing the text error.
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
if (textDeliveryError && !delivery.userVisible) {
if (textDeliveryError && (!delivery.userVisible || finalDeliveryUnknown)) {
textDeliveryError.deliveryReceipt = delivery.receipt;
throw textDeliveryError;
}
if (textDeliveryError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textDeliveryError);
}
if (delivery.userVisible) {
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
}
return delivery.receipt;
} catch (error) {
@ -688,10 +721,15 @@ export class TextHarnessBridge {
}
this.#status.lastError = error?.message ?? String(error);
const presentStreamFailure = async (text) => {
if (typeof stream?.fail !== 'function') return false;
const method = typeof stream?.fail === 'function'
? 'fail'
: (typeof stream?.finish === 'function' ? 'finish' : null);
if (!method) return false;
try {
const result = await stream.fail(text);
return Boolean(result) && result.deliveryOutcome !== 'failed';
const result = await stream[method](text);
return method === 'fail'
? Boolean(result) && result.deliveryOutcome !== 'failed'
: result !== false && result?.deliveryOutcome !== 'failed';
} catch (streamError) {
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] unable to finalize the failed stream:`,
@ -700,58 +738,25 @@ export class TextHarnessBridge {
return false;
}
};
if (failedBatch?.retained) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
error,
);
if (await presentStreamFailure(failedBatch.message)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, failedBatch.message);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
sendError,
);
}
return;
}
const imageErrorMessage = imagePromptUserMessage(error);
if (imageErrorMessage) {
if (await presentStreamFailure(imageErrorMessage)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, imageErrorMessage);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
sendError,
);
}
return;
}
const fileErrorMessage = inboundFileUserMessage(error);
if (fileErrorMessage) {
if (await presentStreamFailure(fileErrorMessage)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, fileErrorMessage);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
sendError,
);
}
return;
}
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
const failure = setLastMessageFailure(this.#status, error, {
userMessage: fileErrorMessage ?? imageErrorMessage,
reason: imagePromptDiagnostic(error)?.reason,
});
const failureText = failedBatch?.retained
? `${messageFailureText(failure)}\n\n${failedBatch.message}`
: messageFailureText(failure);
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
error,
);
if (await presentStreamFailure(failureText)) {
return error.deliveryReceipt;
}
stream?.cancel?.();
try {
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。'));
await this.#bot.sendText(target, failureText);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,

View file

@ -1,5 +1,6 @@
import { connectionTestMessage } from './connection-test.mjs';
import { t } from './i18n.mjs';
import { publicMessageFailure } from './message-failure.mjs';
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -291,6 +292,7 @@ export class TokenBotController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});

View file

@ -1,4 +1,5 @@
import { connectionTestMessage } from '../shared/connection-test.mjs';
import { publicMessageFailure } from '../shared/message-failure.mjs';
import { t } from '../shared/i18n.mjs';
import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
import { inspectSlackCredentials } from './slack-api.mjs';
@ -254,6 +255,7 @@ export class SlackController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});

View file

@ -29,6 +29,7 @@ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
import {
hasInboundImages,
ImagePromptError,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
@ -42,6 +43,12 @@ import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.m
import {
createDeliveryReceipt,
} from '../shared/semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
@ -460,6 +467,7 @@ export function createWecomBridgeStatus() {
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
lastMessageError: null,
};
}
@ -589,8 +597,11 @@ export class WecomHarnessBridge {
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to process a command');
return this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:wecom] failed to process a command [${failure.referenceId}]`,
);
return this.#sendImmediate(frame, chatId, messageFailureText(failure))
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -713,8 +724,11 @@ export class WecomHarnessBridge {
}).catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to process a batch input message');
await this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:wecom] failed to process a batch input message [${failure.referenceId}]`,
);
await this.#sendImmediate(frame, chatId, messageFailureText(failure))
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -791,7 +805,7 @@ export class WecomHarnessBridge {
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt = null) {
if (artifacts.length === 0) {
return { receipt: baseReceipt, failureNoticeVisible: false };
return { receipt: baseReceipt, failureNoticeVisible: false, artifactSendErrors: 0 };
}
const delivery = await deliverOutboundArtifacts({
artifacts,
@ -809,9 +823,13 @@ export class WecomHarnessBridge {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
sendFailureNotice: (artifact, error) => this.#sendActive(
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error),
reason: error?.code,
}),
sendFailureNotice: (_artifact, _error, failure) => this.#sendActive(
chatId,
artifactFailureText(artifact?.fileName, error),
messageFailureText(failure),
),
logger: this.#logger,
});
@ -822,6 +840,7 @@ export class WecomHarnessBridge {
return {
receipt: delivery.receipt,
failureNoticeVisible: delivery.failureNoticeVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
@ -849,6 +868,7 @@ export class WecomHarnessBridge {
let streamId = null;
let streamStarted = false;
let batchSettled = batchSubmission === null;
let promptRecorded = false;
try {
if (!text && !hasImages && !hasFiles) {
await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
@ -909,6 +929,8 @@ export class WecomHarnessBridge {
const content = hasImages
? await promptContentForMessage(message, { signal: this.#signal })
: undefined;
await this.#state.markSeen(messageId);
promptRecorded = true;
const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
@ -982,7 +1004,7 @@ export class WecomHarnessBridge {
});
}
} catch (error) {
textSendError = error;
textSendError = channelDeliveryFailure(error);
this.#logger.warn?.(
'[dsh-im:wecom] final text delivery failed; continuing with result files:',
error,
@ -995,10 +1017,16 @@ export class WecomHarnessBridge {
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
await this.#state.markSeen(messageId);
if (textSendError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textSendError);
}
if (!promptRecorded) await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
if (!textSendError && delivery.artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
return delivery.receipt;
} catch (error) {
let batchFailureMessage = null;
@ -1015,15 +1043,21 @@ export class WecomHarnessBridge {
await this.#client.replyStream(frame, streamId, t('已停止。'), true)
.catch(() => undefined);
}
await this.#state.markSeen(messageId);
if (!promptRecorded) await this.#state.markSeen(messageId);
return;
}
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
const errorText = inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? t('消息处理失败,请稍后重试。');
const userMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error);
const failure = setLastMessageFailure(this.#status, error, {
userMessage,
reason: imagePromptDiagnostic(error)?.reason,
});
this.#logger.error?.(
`[dsh-im:wecom] failed to process an inbound message [${failure.referenceId}]`,
);
const errorText = messageFailureText(failure);
const visibleError = batchFailureMessage
? `${errorText}\n\n${batchFailureMessage}`
: errorText;
@ -1033,7 +1067,7 @@ export class WecomHarnessBridge {
} else {
await this.#sendImmediate(frame, chatId, visibleError);
}
await this.#state.markSeen(messageId);
if (!promptRecorded) await this.#state.markSeen(messageId);
} catch {
this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
}

View file

@ -6,6 +6,7 @@ import {
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { publicMessageFailure } from '../shared/message-failure.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']);
@ -308,6 +309,7 @@ export class WecomController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});

View file

@ -49,10 +49,15 @@ import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import { t } from '../shared/i18n.mjs';
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
const GENERIC_PROCESSING_ERROR = () => t('消息处理失败,请稍后重试。');
const HELP_TEXT = () => [
t('微信已连接 DeepSeek Harness。'),
@ -130,16 +135,6 @@ function canClaimInteractionReply(message, pending) {
&& nonEmptyString(extractWeixinText(message));
}
function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR()) {
const diagnostic = imagePromptDiagnostic(error);
return {
code: diagnostic?.code ?? 'message-processing-failed',
reason: diagnostic?.reason ?? 'UNKNOWN',
message: diagnostic?.userMessage ?? userMessage,
at: Date.now(),
};
}
function artifactFailureText(fileName, error) {
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
switch (error?.code) {
@ -294,9 +289,12 @@ export class WeixinHarnessBridge {
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#status.lastMessageError = safeMessageError(error);
this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
return this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-weixin] failed to process a command [${failure.referenceId}]:`,
error,
);
return this.#send(sender, messageFailureText(failure), contextToken, runId)
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -397,13 +395,15 @@ export class WeixinHarnessBridge {
await this.#send(sender, result.message, contextToken, runId);
}
this.#status.lastError = null;
this.#status.lastMessageError = null;
}).catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#status.lastMessageError = safeMessageError(error);
this.#logger.error?.('[dsh-weixin] failed to process a batch input message:', error);
await this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-weixin] failed to process a batch input message [${failure.referenceId}]:`,
error,
);
await this.#send(sender, messageFailureText(failure), contextToken, runId)
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -457,7 +457,6 @@ export class WeixinHarnessBridge {
if (reply) await this.#send(sender, reply, contextToken, runId);
}
this.#status.lastError = null;
this.#status.lastMessageError = null;
}
async #process(message, key, {
@ -483,6 +482,7 @@ export class WeixinHarnessBridge {
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
let batchSettled = batchSubmission === null;
let promptRecorded = false;
try {
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
const text = promptMessage.content;
@ -543,6 +543,8 @@ export class WeixinHarnessBridge {
let answer;
let artifacts = [];
try {
await this.#state.markSeen(messageId);
promptRecorded = true;
({ answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
@ -586,7 +588,7 @@ export class WeixinHarnessBridge {
providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
});
} catch (error) {
textDeliveryError = error;
textDeliveryError = channelDeliveryFailure(error);
}
const delivery = await this.#deliverArtifacts(
sender,
@ -597,11 +599,16 @@ export class WeixinHarnessBridge {
textReceipt,
);
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
await this.#state.markSeen(messageId);
if (textDeliveryError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textDeliveryError);
}
if (!promptRecorded) await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
this.#status.lastMessageError = null;
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
return delivery.receipt;
} catch (error) {
let batchFailureMessage = null;
@ -614,24 +621,32 @@ export class WeixinHarnessBridge {
batchSettled = true;
}
if (error?.code === 'turn-stopped') {
await this.#state.markSeen(messageId);
if (!promptRecorded) await this.#state.markSeen(messageId);
return;
}
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const userMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? GENERIC_PROCESSING_ERROR();
this.#status.lastMessageError = safeMessageError(error, userMessage);
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
?? imagePromptUserMessage(error);
const imageDiagnostic = imagePromptDiagnostic(error);
const failure = setLastMessageFailure(this.#status, error, {
userMessage,
reason: imageDiagnostic?.reason,
});
this.#logger.error?.(
`[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
error,
);
try {
await this.#send(
sender,
batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
batchFailureMessage
? `${messageFailureText(failure)}\n\n${batchFailureMessage}`
: messageFailureText(failure),
contextToken,
runId,
);
await this.#state.markSeen(messageId);
if (!promptRecorded) await this.#state.markSeen(messageId);
} catch (sendError) {
this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
}
@ -741,7 +756,6 @@ export class WeixinHarnessBridge {
});
this.#clearPendingInteraction(key, pending.interactionId);
this.#status.lastError = null;
this.#status.lastMessageError = null;
} catch (error) {
if (this.#signal?.aborted) return;
if (error?.code === 'interaction-not-pending') {
@ -935,14 +949,17 @@ export class WeixinHarnessBridge {
async #handleInteractionFailure(message, messageId, error) {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#status.lastMessageError = safeMessageError(error);
this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-weixin] failed to process an interaction reply [${failure.referenceId}]:`,
error,
);
if (!this.#state.hasSeen(messageId)) {
await this.#state.markSeen(messageId).catch(() => undefined);
}
await this.#send(
nonEmptyString(message?.from_user_id),
GENERIC_PROCESSING_ERROR(),
messageFailureText(failure),
nonEmptyString(message?.context_token) ?? undefined,
nonEmptyString(message?.run_id) ?? undefined,
).catch(() => undefined);
@ -988,9 +1005,13 @@ export class WeixinHarnessBridge {
sendFile: typeof this.#api.sendFile === 'function'
? (file) => sendArtifact('sendFile', file)
: undefined,
sendFailureNotice: (artifact, error) => this.#send(
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error),
reason: error?.code,
}),
sendFailureNotice: (_artifact, _error, failure) => this.#send(
toUserId,
artifactFailureText(artifact?.fileName, error),
messageFailureText(failure),
contextToken,
runId,
),
@ -1000,6 +1021,10 @@ export class WeixinHarnessBridge {
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
return {
receipt: delivery.receipt,
userVisible: delivery.userVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
}

View file

@ -10,6 +10,7 @@ import {
connectionTestMessage,
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { publicMessageFailure } from '../shared/message-failure.mjs';
import { t } from '../shared/i18n.mjs';
const ACTIVE_ATTEMPT_STATES = new Set([
@ -74,20 +75,6 @@ function safeAccountError(code, message) {
return Object.freeze({ code, message });
}
function publicMessageError(value) {
if (!value || typeof value !== 'object'
|| typeof value.code !== 'string' || !value.code
|| typeof value.reason !== 'string' || !value.reason
|| typeof value.message !== 'string' || !value.message
|| !Number.isFinite(value.at)) return null;
return {
code: value.code.slice(0, 64),
reason: value.reason.slice(0, 128),
message: value.message.slice(0, 500),
at: value.at,
};
}
function activationStageError(code, cause) {
const error = new Error(`Weixin activation failed during ${code}`, { cause });
error.name = 'WeixinActivationStageError';
@ -377,7 +364,7 @@ export class WeixinController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageError(runtimeStatus?.lastMessageError),
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
error: error ? structuredClone(error) : null,
};
});

View file

@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { connectionTestMessage } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { publicMessageFailure } from '../shared/message-failure.mjs';
import {
deriveWhatsappBotId,
maskWhatsappAccount,
@ -285,6 +286,7 @@ export class WhatsappController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
accessPolicy: normalizeWhatsappAccessPolicy(config),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};