mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
feat: add structured message failure reporting
This commit is contained in:
parent
b42fc411c2
commit
ad3c08ace3
59 changed files with 2405 additions and 607 deletions
|
|
@ -830,7 +830,7 @@ export function createDingtalkApi({
|
|||
clientId: appKey,
|
||||
clientSecret: appSecret,
|
||||
cardInstanceId,
|
||||
text: t('消息处理失败,请稍后重试。'),
|
||||
text: t('卡片已结束,请查看后续消息。'),
|
||||
signal: cleanupSignal,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
'目前支持文字、图片和文件消息。':
|
||||
|
|
|
|||
244
src/channels/shared/message-failure.mjs
Normal file
244
src/channels/shared/message-failure.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:`,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue