mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
Merge main into feat/feishu-interaction-cards
# Conflicts: # lib/index.js
This commit is contained in:
commit
d4e1bd3cab
83 changed files with 5516 additions and 597 deletions
|
|
@ -39,7 +39,6 @@ import {
|
|||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
hasInboundFiles,
|
||||
|
|
@ -48,10 +47,16 @@ import {
|
|||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
hasReplyReference,
|
||||
promptContentForInboundMessage,
|
||||
} from '../shared/semantic/reply-reference.mjs';
|
||||
import {
|
||||
createDeliveryReceipt,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
|
||||
import { DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS } from './state-store.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
|
|
@ -76,7 +81,9 @@ const HELP_TEXT_LINES = [
|
|||
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
|
||||
'/workspace 工作区序号或绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/ws、/wsl、/workspaces 工作区命令别名',
|
||||
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/sessionlist --limit N 仅列出当前工作区前 N 个会话',
|
||||
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
||||
'/models 按序号列出所有可用模型',
|
||||
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
|
||||
|
|
@ -185,11 +192,95 @@ function downloadCodeFor(value) {
|
|||
return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
|
||||
}
|
||||
|
||||
function dingtalkTimestampMs(value) {
|
||||
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
||||
if (!Number.isFinite(number) || number < 0) return null;
|
||||
return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
|
||||
}
|
||||
|
||||
function usefulReplyText(value) {
|
||||
const text = nonEmptyString(value);
|
||||
return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function dingtalkReplyReference(message, options) {
|
||||
const replyEnvelope = message?.text;
|
||||
if (replyEnvelope?.isReplyMsg !== true) return null;
|
||||
const replied = replyEnvelope?.repliedMsg;
|
||||
if (!replied || typeof replied !== 'object') {
|
||||
return { unavailableReason: 'not-delivered' };
|
||||
}
|
||||
|
||||
const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
|
||||
const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
|
||||
const pseudoMessage = {
|
||||
msgtype,
|
||||
text: {
|
||||
content: nonEmptyString(repliedContent.text)
|
||||
?? (typeof replied.content === 'string' ? replied.content : ''),
|
||||
},
|
||||
content: repliedContent,
|
||||
};
|
||||
const normalized = dingtalkInboundMessage(pseudoMessage, options);
|
||||
let attachments = [];
|
||||
if (msgtype === 'picture') {
|
||||
attachments = [{ kind: 'image' }];
|
||||
} else if (msgtype === 'file') {
|
||||
const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
|
||||
attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
|
||||
} else if (msgtype === 'richtext') {
|
||||
attachments = richTextEntries(repliedContent)
|
||||
.filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
|
||||
.map(() => ({ kind: 'image' }));
|
||||
} else if (msgtype === 'voice' || msgtype === 'audio') {
|
||||
attachments = [{ kind: 'audio' }];
|
||||
} else if (msgtype === 'video') {
|
||||
attachments = [{ kind: 'video' }];
|
||||
}
|
||||
|
||||
const messageId = nonEmptyString(replied.msgId ?? replied.messageId);
|
||||
const authorId = nonEmptyString(replied.senderId ?? replied.senderStaffId);
|
||||
const authorName = nonEmptyString(replied.senderNick ?? replied.senderName);
|
||||
const content = usefulReplyText(normalized.content)
|
||||
?? usefulReplyText(repliedContent.text)
|
||||
?? usefulReplyText(repliedContent.summary)
|
||||
?? usefulReplyText(repliedContent.title);
|
||||
const processQueryKey = nonEmptyString(
|
||||
message?.originalProcessQueryKey ?? repliedContent.processQueryKey,
|
||||
);
|
||||
const createdAt = dingtalkTimestampMs(replied.createdAt ?? replied.createTime);
|
||||
const load = !content && attachments.length === 0
|
||||
&& typeof options?.loadReplyContent === 'function'
|
||||
? ({ signal } = {}) => options.loadReplyContent({
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(processQueryKey ? { processQueryKey } : {}),
|
||||
...(createdAt === null ? {} : { createdAt }),
|
||||
}, { signal })
|
||||
: null;
|
||||
const supported = [
|
||||
'text', 'picture', 'file', 'richtext', 'voice', 'audio', 'video',
|
||||
'interactivecard', 'chatrecord',
|
||||
]
|
||||
.includes(msgtype);
|
||||
return {
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(authorId ? { authorId } : {}),
|
||||
...(authorName ? { authorName } : {}),
|
||||
...(content ? { content } : {}),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(load ? { load } : {}),
|
||||
...(!content && attachments.length === 0 && !load
|
||||
? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize DingTalk picture and richText callbacks into lazy image references. */
|
||||
export function dingtalkInboundMessage(message, {
|
||||
api,
|
||||
clientId,
|
||||
clientSecret,
|
||||
loadReplyContent,
|
||||
} = {}) {
|
||||
const msgtype = String(message?.msgtype ?? '').toLowerCase();
|
||||
const content = parsedMessageContent(message);
|
||||
|
|
@ -209,6 +300,12 @@ export function dingtalkInboundMessage(message, {
|
|||
}
|
||||
}
|
||||
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
||||
const replyTo = dingtalkReplyReference(message, {
|
||||
api,
|
||||
clientId,
|
||||
clientSecret,
|
||||
loadReplyContent,
|
||||
});
|
||||
return {
|
||||
content: text,
|
||||
images: imageCodes.map((downloadCode, index) => ({
|
||||
|
|
@ -242,6 +339,7 @@ export function dingtalkInboundMessage(message, {
|
|||
});
|
||||
},
|
||||
}] : [],
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -465,11 +563,7 @@ export class DingtalkHarnessBridge {
|
|||
// An unsafe reply route must never be able to submit an approval.
|
||||
}
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const promptMessage = dingtalkInboundMessage(message, {
|
||||
api: this.#api,
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
});
|
||||
const promptMessage = this.#inboundMessage(message, key);
|
||||
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
||||
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
||||
const direct = String(message.conversationType) !== '2';
|
||||
|
|
@ -533,7 +627,8 @@ export class DingtalkHarnessBridge {
|
|||
plainText: Boolean(commandText)
|
||||
&& String(message?.msgtype).toLowerCase() === 'text'
|
||||
&& !hasInboundFiles(promptMessage)
|
||||
&& !hasInboundImages(promptMessage),
|
||||
&& !hasInboundImages(promptMessage)
|
||||
&& !hasReplyReference(promptMessage),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
|
|
@ -778,11 +873,7 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
|
||||
const preparedMessage = hasSafeReplyRoute && addressed
|
||||
? prefetchInboundFiles(dingtalkInboundMessage(message, {
|
||||
api: this.#api,
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
}), { signal: this.#signal })
|
||||
? prefetchInboundFiles(this.#inboundMessage(message, key), { signal: this.#signal })
|
||||
: undefined;
|
||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
|
|
@ -801,6 +892,50 @@ export class DingtalkHarnessBridge {
|
|||
return current;
|
||||
}
|
||||
|
||||
#inboundMessage(message, key) {
|
||||
return dingtalkInboundMessage(message, {
|
||||
api: this.#api,
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
loadReplyContent: (reference, options) => this.#loadReplyContent(key, reference, options),
|
||||
});
|
||||
}
|
||||
|
||||
async #loadReplyContent(key, reference, { signal } = {}) {
|
||||
const indexed = this.#state.recentOutboundTextFor?.({
|
||||
conversationKey: key,
|
||||
...reference,
|
||||
});
|
||||
if (indexed) return { content: indexed };
|
||||
const quotedAt = dingtalkTimestampMs(reference?.createdAt);
|
||||
if (quotedAt === null) return { unavailableReason: 'not-delivered' };
|
||||
const sessionId = this.#state.sessionFor(key);
|
||||
const session = typeof sessionId === 'string' && sessionId
|
||||
? this.#harness.workspaceSession?.(sessionId)
|
||||
: null;
|
||||
const text = await recoverAssistantTextByTimestamp({
|
||||
session,
|
||||
quotedAt,
|
||||
signal,
|
||||
toleranceMs: DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS,
|
||||
});
|
||||
if (!text) return { unavailableReason: 'not-delivered' };
|
||||
try {
|
||||
await this.#state.rememberOutboundMessage?.({
|
||||
conversationKey: key,
|
||||
text,
|
||||
sentAt: quotedAt,
|
||||
completedAt: quotedAt,
|
||||
providerMessageIds: [reference?.processQueryKey, reference?.messageId]
|
||||
.map(nonEmptyString)
|
||||
.filter(Boolean),
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-dingtalk] failed to remember a recovered quote:', error);
|
||||
}
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
async waitForIdle() {
|
||||
await Promise.allSettled([
|
||||
...this.#queues.values(),
|
||||
|
|
@ -936,20 +1071,18 @@ export class DingtalkHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
|
||||
api: this.#api,
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
});
|
||||
const promptMessage = preparedMessage ?? this.#inboundMessage(message, key);
|
||||
const text = promptMessage.content;
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
const hasFiles = hasInboundFiles(promptMessage);
|
||||
const hasReply = hasReplyReference(promptMessage);
|
||||
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
||||
let cardStream = null;
|
||||
let cardStarted = false;
|
||||
let cardStartedAt = null;
|
||||
let batchSettled = batchSubmission === null;
|
||||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
if (!text && !hasImages && !hasFiles && !hasReply) {
|
||||
await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'), this.#atUsersFor(message));
|
||||
return;
|
||||
}
|
||||
|
|
@ -992,8 +1125,8 @@ export class DingtalkHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
let content = hasImages || hasReply
|
||||
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
|
|
@ -1004,6 +1137,7 @@ export class DingtalkHarnessBridge {
|
|||
senderId: sender,
|
||||
senderName: message.senderNick,
|
||||
conversationTitle: message.conversationTitle,
|
||||
chatId: message.conversationId,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
|
|
@ -1018,7 +1152,9 @@ export class DingtalkHarnessBridge {
|
|||
signal: this.#signal,
|
||||
logger: this.#logger,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
|
||||
if (cardStarted) cardStartedAt = startedAt;
|
||||
}
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
|
|
@ -1056,12 +1192,14 @@ export class DingtalkHarnessBridge {
|
|||
let textDeliveryError = null;
|
||||
let textReceipt = null;
|
||||
let streamed = false;
|
||||
const deliveryStartedAt = cardStartedAt ?? Date.now();
|
||||
try {
|
||||
streamed = cardStarted && await cardStream.finish(answerText);
|
||||
if (streamed) {
|
||||
textReceipt = createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'dingtalk-card',
|
||||
providerMessageIds: cardStream.providerMessageIds,
|
||||
});
|
||||
} else {
|
||||
textReceipt = createDeliveryReceipt({
|
||||
|
|
@ -1070,6 +1208,17 @@ export class DingtalkHarnessBridge {
|
|||
providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.#state.rememberOutboundMessage?.({
|
||||
conversationKey: key,
|
||||
text: answerText,
|
||||
sentAt: deliveryStartedAt,
|
||||
completedAt: Date.now(),
|
||||
providerMessageIds: providerMessageIdsFor(textReceipt),
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-dingtalk] failed to remember an outbound message:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
textDeliveryError = channelDeliveryFailure(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function requiredCredential(value, name) {
|
|||
* @param {number} [options.updateIntervalMs=500] Minimum delay between updates.
|
||||
* @param {()=>number} [options.clock] Monotonic millisecond clock.
|
||||
* @param {{setTimeout: Function, clearTimeout: Function}} [options.timer] Timer implementation.
|
||||
* @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>}}
|
||||
* @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>, readonly providerMessageIds: string[]}}
|
||||
* Card stream controller.
|
||||
*/
|
||||
export function createDingTalkCardStream({
|
||||
|
|
@ -231,5 +231,12 @@ export function createDingTalkCardStream({
|
|||
return finishPromise;
|
||||
};
|
||||
|
||||
return Object.freeze({ start, push, finish });
|
||||
return Object.freeze({
|
||||
start,
|
||||
push,
|
||||
finish,
|
||||
get providerMessageIds() {
|
||||
return cardRequest?.cardInstanceId ? [cardRequest.cardInstanceId] : [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ const EMPTY_STATE = Object.freeze({
|
|||
sessions: {},
|
||||
seenMessageIds: [],
|
||||
pendingSenders: {},
|
||||
recentOutboundMessages: [],
|
||||
});
|
||||
|
||||
export const DINGTALK_RECENT_OUTBOUND_LIMIT = 200;
|
||||
export const DINGTALK_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
||||
export const DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
|
||||
export const DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
|
@ -19,6 +25,49 @@ function displayName(value) {
|
|||
return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
|
||||
}
|
||||
|
||||
function timestampMs(value) {
|
||||
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
||||
if (!Number.isFinite(number) || number < 0) return null;
|
||||
return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
|
||||
}
|
||||
|
||||
function providerMessageIds(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value
|
||||
.map((id) => (id === undefined || id === null ? null : nonEmptyString(String(id))))
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function truncateText(value) {
|
||||
const text = nonEmptyString(value);
|
||||
return text ? [...text].slice(0, DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT).join('') : null;
|
||||
}
|
||||
|
||||
function normalizeRecentOutboundMessage(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const conversationKey = nonEmptyString(value.conversationKey);
|
||||
const text = truncateText(value.text);
|
||||
const sentAt = timestampMs(value.sentAt);
|
||||
const completedAt = timestampMs(value.completedAt) ?? sentAt;
|
||||
if (!conversationKey || !text || sentAt === null || completedAt === null) return null;
|
||||
return {
|
||||
conversationKey,
|
||||
text,
|
||||
sentAt,
|
||||
completedAt: Math.max(sentAt, completedAt),
|
||||
providerMessageIds: providerMessageIds(value.providerMessageIds),
|
||||
};
|
||||
}
|
||||
|
||||
function recentOutboundMessages(value, now = Date.now()) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const cutoff = now - DINGTALK_RECENT_OUTBOUND_TTL_MS;
|
||||
return value
|
||||
.map(normalizeRecentOutboundMessage)
|
||||
.filter((entry) => entry && entry.completedAt >= cutoff)
|
||||
.slice(-DINGTALK_RECENT_OUTBOUND_LIMIT);
|
||||
}
|
||||
|
||||
function normalizePendingSender(value, fallbackRequestId) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const requestId = nonEmptyString(value.requestId) ?? nonEmptyString(fallbackRequestId);
|
||||
|
|
@ -69,6 +118,7 @@ function normalizeState(value) {
|
|||
? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000)
|
||||
: [],
|
||||
pendingSenders,
|
||||
recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +190,54 @@ export class DingtalkStateStore {
|
|||
await this.#persist();
|
||||
}
|
||||
|
||||
async rememberOutboundMessage({
|
||||
conversationKey,
|
||||
text,
|
||||
sentAt = Date.now(),
|
||||
completedAt = Date.now(),
|
||||
providerMessageIds: messageIds = [],
|
||||
} = {}) {
|
||||
const entry = normalizeRecentOutboundMessage({
|
||||
conversationKey,
|
||||
text,
|
||||
sentAt,
|
||||
completedAt,
|
||||
providerMessageIds: messageIds,
|
||||
});
|
||||
if (!entry) throw new TypeError('Invalid DingTalk outbound message');
|
||||
this.#state.recentOutboundMessages = recentOutboundMessages([
|
||||
...this.#state.recentOutboundMessages,
|
||||
entry,
|
||||
]);
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
recentOutboundTextFor({
|
||||
conversationKey,
|
||||
processQueryKey,
|
||||
messageId,
|
||||
createdAt,
|
||||
now = Date.now(),
|
||||
} = {}) {
|
||||
const key = nonEmptyString(conversationKey);
|
||||
if (!key) return null;
|
||||
const active = recentOutboundMessages(this.#state.recentOutboundMessages, now)
|
||||
.filter((entry) => entry.conversationKey === key);
|
||||
const quotedIds = providerMessageIds([processQueryKey, messageId]);
|
||||
for (const quotedId of quotedIds) {
|
||||
const exact = active.filter((entry) => entry.providerMessageIds.includes(quotedId));
|
||||
if (exact.length === 1) return exact[0].text;
|
||||
if (exact.length > 1) return null;
|
||||
}
|
||||
const quotedAt = timestampMs(createdAt);
|
||||
if (quotedAt === null) return null;
|
||||
const candidates = active.filter((entry) => (
|
||||
quotedAt >= entry.sentAt - DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
||||
&& quotedAt <= entry.completedAt + DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
||||
));
|
||||
return candidates.length === 1 ? candidates[0].text : null;
|
||||
}
|
||||
|
||||
pendingSenders() {
|
||||
return Object.values(this.#state.pendingSenders)
|
||||
.sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))
|
||||
|
|
|
|||
|
|
@ -134,6 +134,13 @@ export class DiscordApi {
|
|||
});
|
||||
}
|
||||
|
||||
getMessage({ channelId, messageId, signal } = {}) {
|
||||
return this.#request(
|
||||
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
|
||||
{ method: 'GET', signal },
|
||||
);
|
||||
}
|
||||
|
||||
startThreadFromMessage({ channelId, messageId, name, signal } = {}) {
|
||||
const threadName = cleanString(name);
|
||||
if (!threadName || [...threadName].length > 100) {
|
||||
|
|
|
|||
|
|
@ -206,12 +206,101 @@ function discordFileSource(attachment, fetchImpl) {
|
|||
};
|
||||
}
|
||||
|
||||
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
||||
function discordReplyAttachment(attachment) {
|
||||
if (!attachment || typeof attachment !== 'object') return null;
|
||||
const mediaType = typeof attachment.content_type === 'string'
|
||||
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
|
||||
const kind = mediaType.startsWith('image/') ? 'image'
|
||||
: mediaType.startsWith('audio/') ? 'audio'
|
||||
: mediaType.startsWith('video/') ? 'video' : 'file';
|
||||
const name = typeof attachment.filename === 'string' && attachment.filename
|
||||
? attachment.filename : undefined;
|
||||
return { kind, ...(name ? { name } : {}) };
|
||||
}
|
||||
|
||||
function discordReplySnapshot(message, fallbackMessageId) {
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
const messageId = typeof message.id === 'string' && message.id
|
||||
? message.id : fallbackMessageId;
|
||||
const authorId = typeof message.author?.id === 'string' && message.author.id
|
||||
? message.author.id : undefined;
|
||||
const authorName = [message.member?.nick, message.author?.global_name, message.author?.username]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
const attachments = Array.isArray(message.attachments)
|
||||
? message.attachments.map(discordReplyAttachment).filter(Boolean)
|
||||
: [];
|
||||
if (Array.isArray(message.sticker_items)) {
|
||||
attachments.push(...message.sticker_items.map((sticker) => ({
|
||||
kind: 'image',
|
||||
...(typeof sticker?.name === 'string' && sticker.name ? { name: sticker.name } : {}),
|
||||
})));
|
||||
}
|
||||
return {
|
||||
...(messageId ? { messageId: String(messageId) } : {}),
|
||||
...(authorId ? { authorId } : {}),
|
||||
...(authorName ? { authorName } : {}),
|
||||
content: typeof message.content === 'string' ? message.content : '',
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
function discordReplyReference(message, loadReply) {
|
||||
const channelId = String(message?.channel_id ?? '');
|
||||
const referenceId = typeof message?.message_reference?.message_id === 'string'
|
||||
&& message.message_reference.message_id
|
||||
? message.message_reference.message_id : undefined;
|
||||
const referenceChannelId = message?.message_reference?.channel_id;
|
||||
if (referenceChannelId !== undefined && String(referenceChannelId) !== channelId) {
|
||||
return {
|
||||
...(referenceId ? { messageId: referenceId } : {}),
|
||||
unavailableReason: 'not-found',
|
||||
};
|
||||
}
|
||||
if (Object.hasOwn(message ?? {}, 'referenced_message')) {
|
||||
if (message.referenced_message === null) {
|
||||
return {
|
||||
...(referenceId ? { messageId: referenceId } : {}),
|
||||
unavailableReason: 'deleted',
|
||||
};
|
||||
}
|
||||
if (message.referenced_message && typeof message.referenced_message === 'object') {
|
||||
const snapshotId = typeof message.referenced_message.id === 'string'
|
||||
&& message.referenced_message.id ? message.referenced_message.id : undefined;
|
||||
if (String(message.referenced_message.channel_id ?? '') !== channelId
|
||||
|| !snapshotId || (referenceId && snapshotId !== referenceId)) {
|
||||
return {
|
||||
...(referenceId ? { messageId: referenceId } : {}),
|
||||
unavailableReason: 'not-found',
|
||||
};
|
||||
}
|
||||
return discordReplySnapshot(message.referenced_message, referenceId) ?? undefined;
|
||||
}
|
||||
}
|
||||
if (!referenceId) return undefined;
|
||||
if (typeof loadReply !== 'function') {
|
||||
return { messageId: referenceId, unavailableReason: 'not-delivered' };
|
||||
}
|
||||
return {
|
||||
messageId: referenceId,
|
||||
load: async ({ signal } = {}) => {
|
||||
const referenced = await loadReply({ channelId, messageId: referenceId, signal });
|
||||
if (!referenced || String(referenced.id ?? '') !== referenceId
|
||||
|| String(referenced.channel_id ?? '') !== channelId) return null;
|
||||
return discordReplySnapshot(referenced, referenceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDiscordMessage(message, botId, {
|
||||
fetchImpl = fetch,
|
||||
loadReply,
|
||||
} = {}) {
|
||||
if (!message?.id || !message?.channel_id || !message?.author?.id
|
||||
|| Number(message.type) === 21) return null;
|
||||
const direct = !message.guild_id;
|
||||
const addressed = direct
|
||||
|| message.mentions?.some((mention) => String(mention?.id) === String(botId));
|
||||
const replyTo = discordReplyReference(message, loadReply);
|
||||
return {
|
||||
messageId: String(message.id),
|
||||
senderId: String(message.author.id),
|
||||
|
|
@ -232,6 +321,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|||
files: Array.isArray(message.attachments)
|
||||
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
|
||||
: [],
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
addressed,
|
||||
replyTarget: {
|
||||
channelId: String(message.channel_id),
|
||||
|
|
@ -252,7 +342,12 @@ export async function resolveDiscordMessageRoute(message, botId, {
|
|||
signal,
|
||||
onChannel,
|
||||
} = {}) {
|
||||
const normalized = normalizeDiscordMessage(message, botId, { fetchImpl });
|
||||
const normalized = normalizeDiscordMessage(message, botId, {
|
||||
fetchImpl,
|
||||
loadReply: typeof api?.getMessage === 'function'
|
||||
? (options) => api.getMessage(options)
|
||||
: undefined,
|
||||
});
|
||||
if (!normalized || normalized.senderIsBot) return normalized;
|
||||
signal?.throwIfAborted();
|
||||
if (normalized.kind === 'direct') {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ import {
|
|||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
hasReplyReference,
|
||||
promptContentForInboundMessage,
|
||||
} from '../shared/semantic/reply-reference.mjs';
|
||||
import {
|
||||
hasInboundFiles,
|
||||
inboundFileUserMessage,
|
||||
|
|
@ -44,7 +47,12 @@ import {
|
|||
isPresetCommand,
|
||||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||
import {
|
||||
parseSessionListArgument,
|
||||
resolveSessionListWorkspace,
|
||||
runWorkspaceCommand,
|
||||
workspacePathSnapshot,
|
||||
} from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
|
|
@ -101,7 +109,7 @@ const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
|
|||
const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
|
||||
const WATCHLIST_COMMAND = /^\/watchlist$/i;
|
||||
const SESSION_LIST_PREFIX = /^\/(?:sessionlist|sessions)(?:\s|$)/i;
|
||||
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
||||
const WORKSPACE_LIST_COMMAND = /^\/(?:workspacelist|workspaces|wsl)$/i;
|
||||
const NUMBER_REPLY = /^\d{1,2}$/;
|
||||
/** A displayed menu stays number-tappable for this long. */
|
||||
const MENU_TTL_MS = 10 * 60_000;
|
||||
|
|
@ -140,7 +148,7 @@ const REPAIR_URL_HOSTS = new Set([
|
|||
|
||||
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
||||
/** Matches fast card commands that should not be queued behind a running task. */
|
||||
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
|
||||
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
|
||||
|
||||
/** Pretty-print a tool call's arguments for an approval card. */
|
||||
function operationArguments(toolCall) {
|
||||
|
|
@ -186,6 +194,7 @@ const WORKSPACE_HELP_LINES = [
|
|||
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/sessionlist --limit N 仅列出当前工作区前 N 个会话',
|
||||
];
|
||||
|
||||
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
||||
|
|
@ -659,7 +668,9 @@ export class FeishuHarnessBridge {
|
|||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, batchText, {
|
||||
plainText: event.message.message_type === 'text' && Boolean(batchText),
|
||||
plainText: event.message.message_type === 'text'
|
||||
&& Boolean(batchText)
|
||||
&& !hasReplyReference(commandMessage),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
|
|
@ -1065,11 +1076,12 @@ export class FeishuHarnessBridge {
|
|||
const text = message.content;
|
||||
const hasImages = hasInboundImages(message);
|
||||
const hasFiles = hasInboundFiles(message);
|
||||
const hasReply = hasReplyReference(message);
|
||||
// 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
|
||||
// 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
|
||||
// accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
|
||||
const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
if (!text && !hasImages && !hasFiles && !hasReply) {
|
||||
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
|
||||
return;
|
||||
}
|
||||
|
|
@ -1112,8 +1124,18 @@ export class FeishuHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (SESSION_LIST_PREFIX.test(commandText)) {
|
||||
const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null;
|
||||
await this.#showSessions({ chatId: event.message.chat_id, key, replyTo: event.message.message_id }, selector, 0);
|
||||
const argument = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim();
|
||||
const request = parseSessionListArgument(argument);
|
||||
if (request.error) {
|
||||
await this.#send(event.message.chat_id, request.error, { replyTo: event.message.message_id });
|
||||
return;
|
||||
}
|
||||
await this.#showSessions(
|
||||
{ chatId: event.message.chat_id, key, replyTo: event.message.message_id },
|
||||
request.selector || null,
|
||||
0,
|
||||
{ limit: request.limit },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (WORKSPACE_LIST_COMMAND.test(commandText)) {
|
||||
|
|
@ -1877,6 +1899,7 @@ export class FeishuHarnessBridge {
|
|||
messageId = null,
|
||||
sessionWorkspace = null,
|
||||
sessionPage = 0,
|
||||
sessionLimit = null,
|
||||
selections = [],
|
||||
actor = null,
|
||||
}) {
|
||||
|
|
@ -1927,7 +1950,7 @@ export class FeishuHarnessBridge {
|
|||
{ chatId, key, replyTo: messageId },
|
||||
sessionWorkspace,
|
||||
page,
|
||||
{ updateMessageId: messageId },
|
||||
{ updateMessageId: messageId, limit: sessionLimit },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -2106,7 +2129,7 @@ export class FeishuHarnessBridge {
|
|||
{ chatId, key, replyTo: messageId },
|
||||
sessionWorkspace,
|
||||
sessionPage,
|
||||
{ updateMessageId: messageId },
|
||||
{ updateMessageId: messageId, limit: sessionLimit },
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
|
@ -2122,7 +2145,7 @@ export class FeishuHarnessBridge {
|
|||
{ chatId, key, replyTo: messageId },
|
||||
sessionWorkspace,
|
||||
sessionPage,
|
||||
{ updateMessageId: messageId },
|
||||
{ updateMessageId: messageId, limit: sessionLimit },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2207,7 +2230,7 @@ export class FeishuHarnessBridge {
|
|||
{ chatId, key, replyTo = null },
|
||||
selector,
|
||||
page = 0,
|
||||
{ updateMessageId = null } = {},
|
||||
{ updateMessageId = null, limit = null } = {},
|
||||
) {
|
||||
try {
|
||||
const signal = this.#cardDataSignal();
|
||||
|
|
@ -2217,7 +2240,11 @@ export class FeishuHarnessBridge {
|
|||
return;
|
||||
}
|
||||
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
|
||||
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
||||
const visibleSessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
||||
const sessionLimit = Number.isSafeInteger(limit) && limit > 0 ? limit : null;
|
||||
const sessions = sessionLimit === null
|
||||
? visibleSessions
|
||||
: visibleSessions.slice(0, sessionLimit);
|
||||
const workspace = listed?.workspace ?? resolved.workspace;
|
||||
if (sessions.length === 0) {
|
||||
await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }), { replyTo });
|
||||
|
|
@ -2244,6 +2271,7 @@ export class FeishuHarnessBridge {
|
|||
// list response's workspace is display data and is not authoritative.
|
||||
sessionWorkspace: resolved.workspace,
|
||||
sessionPage: safePage,
|
||||
sessionLimit,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
@ -2312,6 +2340,9 @@ export class FeishuHarnessBridge {
|
|||
sessionPage: Number.isSafeInteger(options.sessionPage) && options.sessionPage >= 0
|
||||
? options.sessionPage
|
||||
: 0,
|
||||
sessionLimit: Number.isSafeInteger(options.sessionLimit) && options.sessionLimit > 0
|
||||
? options.sessionLimit
|
||||
: null,
|
||||
});
|
||||
if (this.#cardKeys.size > 200) {
|
||||
const oldest = this.#cardKeys.keys().next().value;
|
||||
|
|
@ -3361,8 +3392,8 @@ export class FeishuHarnessBridge {
|
|||
askCompleted = true;
|
||||
onAskComplete?.();
|
||||
};
|
||||
let content = hasInboundImages(message)
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
let content = hasInboundImages(message) || hasReplyReference(message)
|
||||
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
|
|
@ -3371,6 +3402,8 @@ export class FeishuHarnessBridge {
|
|||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'feishu',
|
||||
senderId: senderOpenId(event),
|
||||
chatId: event.message.chat_id,
|
||||
threadId: event.message.thread_id,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -488,9 +488,11 @@ export function menuHelpText() {
|
|||
'',
|
||||
'📋 会话 / 工作区',
|
||||
'/sessionlist 或 /sessions 列出工作区会话',
|
||||
'/sessionlist --limit N 仅列出当前工作区前 N 个会话',
|
||||
'/session ID 绑定已有会话',
|
||||
'/workspacelist 列出工作区',
|
||||
'/workspace 工作区序号或绝对路径 切换工作区',
|
||||
'/ws、/wsl、/workspaces 工作区命令别名',
|
||||
'/new 开启全新会话',
|
||||
'',
|
||||
'📊 状态 / 压缩',
|
||||
|
|
@ -554,6 +556,7 @@ const HELP_TEXT_COMMANDS = [
|
|||
'`/new` — 开启全新会话',
|
||||
'`/session ID` — 绑定已有会话',
|
||||
'`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话',
|
||||
'`/sessionlist --limit N` 或 `/sessions --limit N` — 仅列出当前工作区前 N 个会话',
|
||||
'`/workspace 工作区序号或绝对路径` — 切换工作区',
|
||||
'`/workspacelist` — 列出工作区',
|
||||
'`/status` — 查看连接状态',
|
||||
|
|
@ -593,6 +596,7 @@ export function helpCard(extraTextLines = []) {
|
|||
{ tag: 'hr' },
|
||||
{ tag: 'div', text: markdown([
|
||||
t(HELP_TEXT_COMMANDS),
|
||||
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
||||
t('`/version` — 查看插件版本'),
|
||||
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
||||
].join('\n') + extraText) },
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@ import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
||||
const FEISHU_CARD_MESSAGE_CONTENT_TYPE = 'raw_card_content';
|
||||
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
||||
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
||||
const FEISHU_CARD_TEXT_MAX_DEPTH = 12;
|
||||
const FEISHU_CARD_TEXT_MAX_NODES = 1_000;
|
||||
const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
|
||||
'请升级至最新版本客户端,以查看内容',
|
||||
]);
|
||||
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
||||
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
||||
|
||||
|
|
@ -56,6 +62,124 @@ function nonEmptyString(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function objectRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function jsonRecord(value) {
|
||||
if (objectRecord(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
try {
|
||||
return objectRecord(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function interactiveCardRoot(parsed) {
|
||||
const root = objectRecord(parsed);
|
||||
if (!root) return null;
|
||||
// raw_card_content wraps CardKit entities in json_card. Keep direct Card
|
||||
// 1.0/2.0 payloads readable as well for historical messages and fixtures.
|
||||
return jsonRecord(root.json_card) ?? jsonRecord(root.card) ?? root;
|
||||
}
|
||||
|
||||
function cardProperty(value) {
|
||||
const record = objectRecord(value);
|
||||
return objectRecord(record?.property) ?? record;
|
||||
}
|
||||
|
||||
function cardTextContent(property) {
|
||||
const i18n = objectRecord(property?.i18nContent);
|
||||
const content = nonEmptyString(i18n?.zh_cn)
|
||||
?? nonEmptyString(i18n?.en_us)
|
||||
?? nonEmptyString(i18n?.ja_jp)
|
||||
?? nonEmptyString(property?.content)
|
||||
?? nonEmptyString(property?.text);
|
||||
return content && !FEISHU_CARD_UNAVAILABLE_TEXTS.has(content) ? content : null;
|
||||
}
|
||||
|
||||
function cardElementText(
|
||||
element,
|
||||
{ depth = 0, inline = false, budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES } } = {},
|
||||
) {
|
||||
if (depth > FEISHU_CARD_TEXT_MAX_DEPTH || budget.remaining <= 0) return '';
|
||||
budget.remaining -= 1;
|
||||
if (Array.isArray(element)) {
|
||||
return element
|
||||
.map((part) => cardElementText(part, {
|
||||
depth: depth + 1,
|
||||
inline: Array.isArray(part),
|
||||
budget,
|
||||
}))
|
||||
.filter(Boolean)
|
||||
.join(inline ? ' ' : '\n');
|
||||
}
|
||||
const value = objectRecord(element);
|
||||
if (!value) return '';
|
||||
const property = cardProperty(value);
|
||||
if (!property) return '';
|
||||
const tag = String(value.tag ?? '').toLowerCase();
|
||||
|
||||
if (
|
||||
tag === 'markdown'
|
||||
|| tag === 'markdown_v1'
|
||||
|| tag === 'lark_md'
|
||||
|| tag === 'plain_text'
|
||||
|| tag === 'text'
|
||||
) {
|
||||
const content = cardTextContent(property);
|
||||
if (content) return content;
|
||||
const nested = cardElementText(property.elements, {
|
||||
depth: depth + 1,
|
||||
inline: true,
|
||||
budget,
|
||||
});
|
||||
if (nested || tag !== 'markdown_v1') return nested;
|
||||
return cardElementText(value.fallback ?? property.fallback, {
|
||||
depth: depth + 1,
|
||||
inline: true,
|
||||
budget,
|
||||
});
|
||||
}
|
||||
if (tag === 'a' || tag === 'link' || tag === 'button') {
|
||||
if (typeof property.text === 'string') return nonEmptyString(property.text) ?? '';
|
||||
return cardElementText(property.text, { depth: depth + 1, inline: true, budget });
|
||||
}
|
||||
if (tag === 'div') {
|
||||
return [
|
||||
cardElementText(property.text, { depth: depth + 1, budget }),
|
||||
cardElementText(property.fields, { depth: depth + 1, budget }),
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// Traverse visible layout containers only. Deliberately ignore callback
|
||||
// values, form state, URLs, ids, template variables and other hidden data.
|
||||
return ['elements', 'columns', 'fields', 'children', 'actions']
|
||||
.map((key) => cardElementText(property[key], { depth: depth + 1, budget }))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function interactiveCardText(parsed) {
|
||||
const card = interactiveCardRoot(parsed);
|
||||
if (!card) return '';
|
||||
const budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES };
|
||||
const header = cardProperty(card.header);
|
||||
const title = [
|
||||
cardElementText(header?.title, { inline: true, budget }),
|
||||
cardElementText(header?.subtitle, { inline: true, budget }),
|
||||
].filter(Boolean).join('\n') || nonEmptyString(card.title) || '';
|
||||
const body = cardProperty(card.body);
|
||||
const elements = Array.isArray(body?.elements)
|
||||
? body.elements
|
||||
: Array.isArray(card.elements)
|
||||
? card.elements
|
||||
: null;
|
||||
const content = cardElementText(elements, { budget });
|
||||
return [title, content].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function postContent(event, parsed = parsedMessageContent(event)) {
|
||||
if (event?.message?.message_type !== 'post') return null;
|
||||
if (!parsed) return null;
|
||||
|
|
@ -283,6 +407,109 @@ function feishuFileSource(event, client, file) {
|
|||
};
|
||||
}
|
||||
|
||||
function feishuReplyTargetId(event) {
|
||||
const parentId = nonEmptyString(event?.message?.parent_id);
|
||||
if (parentId) return parentId;
|
||||
const rootId = nonEmptyString(event?.message?.root_id);
|
||||
const messageId = nonEmptyString(event?.message?.message_id);
|
||||
return rootId && rootId !== messageId ? rootId : null;
|
||||
}
|
||||
|
||||
function feishuReplyAttachments(messageType, parsed, post) {
|
||||
if (messageType === 'post') {
|
||||
return (post?.imageKeys ?? []).map(() => ({ kind: 'image' }));
|
||||
}
|
||||
if (messageType === 'image') return [{ kind: 'image' }];
|
||||
if (messageType === 'file') {
|
||||
return [{
|
||||
kind: 'file',
|
||||
...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
|
||||
}];
|
||||
}
|
||||
if (messageType === 'audio') return [{ kind: 'audio' }];
|
||||
if (messageType === 'media') {
|
||||
return [{
|
||||
kind: 'video',
|
||||
...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
|
||||
}];
|
||||
}
|
||||
if (messageType === 'sticker') return [{ kind: 'other' }];
|
||||
return [];
|
||||
}
|
||||
|
||||
function feishuReplyReference(event, client) {
|
||||
const messageId = feishuReplyTargetId(event);
|
||||
if (!messageId) return null;
|
||||
const chatId = nonEmptyString(event?.message?.chat_id);
|
||||
return {
|
||||
messageId,
|
||||
async load({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
let response;
|
||||
try {
|
||||
response = await client?.im?.v1?.message?.get?.({
|
||||
path: { message_id: messageId },
|
||||
params: {
|
||||
with_sender_name: true,
|
||||
card_msg_content_type: FEISHU_CARD_MESSAGE_CONTENT_TYPE,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (await feishuProviderCode(error, signal) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
||||
return { messageId, unavailableReason: 'permission-denied' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (providerCode(response) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
||||
return { messageId, unavailableReason: 'permission-denied' };
|
||||
}
|
||||
if (providerCode(response) !== null && providerCode(response) !== 0) {
|
||||
return { messageId, unavailableReason: 'not-delivered' };
|
||||
}
|
||||
const item = response?.data?.items?.find?.(
|
||||
(candidate) => nonEmptyString(candidate?.message_id) === messageId,
|
||||
);
|
||||
if (!item) return { messageId, unavailableReason: 'not-found' };
|
||||
if (item.deleted) return { messageId, unavailableReason: 'deleted' };
|
||||
const itemChatId = nonEmptyString(item.chat_id);
|
||||
if (!chatId || !itemChatId || itemChatId !== chatId) {
|
||||
return { messageId, unavailableReason: 'not-found' };
|
||||
}
|
||||
|
||||
const messageType = nonEmptyString(item.msg_type) ?? '';
|
||||
const quotedEvent = {
|
||||
message: {
|
||||
message_id: messageId,
|
||||
message_type: messageType,
|
||||
content: item.body?.content,
|
||||
mentions: item.mentions ?? [],
|
||||
},
|
||||
};
|
||||
const parsed = parsedMessageContent(quotedEvent);
|
||||
const post = postContent(quotedEvent, parsed);
|
||||
const quoted = extractInboundMessage(quotedEvent, client);
|
||||
const content = messageType === 'interactive'
|
||||
? interactiveCardText(parsed)
|
||||
: quoted.content;
|
||||
const attachments = feishuReplyAttachments(messageType, parsed, post);
|
||||
return {
|
||||
messageId,
|
||||
...(nonEmptyString(item.sender?.id) ? { authorId: nonEmptyString(item.sender.id) } : {}),
|
||||
...(nonEmptyString(item.sender?.sender_name)
|
||||
? { authorName: nonEmptyString(item.sender.sender_name) }
|
||||
: {}),
|
||||
...(content ? { content } : {}),
|
||||
attachments,
|
||||
...(messageType === 'interactive' && !content && attachments.length === 0
|
||||
? { unavailableReason: 'unsupported' }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractInboundMessage(event, client) {
|
||||
const messageType = event?.message?.message_type;
|
||||
const parsed = parsedMessageContent(event);
|
||||
|
|
@ -292,10 +519,12 @@ export function extractInboundMessage(event, client) {
|
|||
: null;
|
||||
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
||||
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
|
||||
const replyTo = feishuReplyReference(event, client);
|
||||
return {
|
||||
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
||||
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
||||
files: file ? [file] : [],
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
|
|||
{ command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
|
||||
{ command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
|
||||
{ command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
||||
{ command: 'workspaces', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
||||
{ command: 'wsl', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
||||
{ command: 'ws', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
|
||||
{ command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
|
||||
{ command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
|
||||
{ command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import {
|
|||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
hasInboundFiles,
|
||||
|
|
@ -44,6 +43,10 @@ import {
|
|||
trackOutboundArtifactProviderPromise,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
hasReplyReference,
|
||||
promptContentForInboundMessage,
|
||||
} from '../shared/semantic/reply-reference.mjs';
|
||||
import {
|
||||
createDeliveryReceipt,
|
||||
providerMessageIdsFor,
|
||||
|
|
@ -87,7 +90,9 @@ function helpText() {
|
|||
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
||||
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
||||
t('/workspacelist 列出工作区绝对路径'),
|
||||
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
||||
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
||||
t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
|
||||
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
||||
t('/models 按序号列出所有可用模型'),
|
||||
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
|
||||
|
|
@ -145,6 +150,37 @@ function hasQqFileAttachments(message) {
|
|||
&& message.attachments.some((attachment) => !isQqImageAttachment(attachment));
|
||||
}
|
||||
|
||||
function qqAttachmentKind(attachment) {
|
||||
const mediaType = attachmentMediaType(attachment);
|
||||
if (mediaType?.startsWith('image/')) return 'image';
|
||||
if (mediaType?.startsWith('audio/')) return 'audio';
|
||||
if (mediaType?.startsWith('video/')) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function qqReplyReference(message) {
|
||||
const refMsgIdx = nonEmptyString(message?.refMsgIdx);
|
||||
if (!refMsgIdx) return null;
|
||||
const element = Array.isArray(message?.msgElements) ? message.msgElements[0] : null;
|
||||
const sourceAttachments = Array.isArray(element?.attachments) ? element.attachments : [];
|
||||
const attachments = sourceAttachments.map((attachment) => {
|
||||
const name = nonEmptyString(attachment?.filename);
|
||||
return { kind: qqAttachmentKind(attachment), ...(name ? { name } : {}) };
|
||||
});
|
||||
const asrText = sourceAttachments
|
||||
.filter((attachment) => qqAttachmentKind(attachment) === 'audio')
|
||||
.map((attachment) => nonEmptyString(attachment?.asr_refer_text))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const content = asrText || nonEmptyString(element?.content);
|
||||
return {
|
||||
messageId: refMsgIdx,
|
||||
...(content ? { content } : {}),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(!content && attachments.length === 0 ? { unavailableReason: 'not-delivered' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
|
||||
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
|
||||
const response = await fetchImpl(new URL(normalizedUrl), {
|
||||
|
|
@ -196,7 +232,13 @@ export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
|
|||
},
|
||||
});
|
||||
}
|
||||
return { content: safeText(message), images, files };
|
||||
const replyTo = qqReplyReference(message);
|
||||
return {
|
||||
content: safeText(message),
|
||||
images,
|
||||
files,
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
|
|
@ -493,7 +535,8 @@ export class QqHarnessBridge {
|
|||
: this.#batchInputs.handle(key, commandText, {
|
||||
plainText: Boolean(commandText)
|
||||
&& !hasQqImageAttachments(message)
|
||||
&& !hasQqFileAttachments(message),
|
||||
&& !hasQqFileAttachments(message)
|
||||
&& !qqReplyReference(message),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
|
|
@ -785,10 +828,11 @@ export class QqHarnessBridge {
|
|||
const text = promptMessage.content;
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
const hasFiles = hasInboundFiles(promptMessage);
|
||||
const hasReply = hasReplyReference(promptMessage);
|
||||
let stream = null;
|
||||
let batchSettled = batchSubmission === null;
|
||||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
if (!text && !hasImages && !hasFiles && !hasReply) {
|
||||
await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
|
||||
await markMessageSeen();
|
||||
return;
|
||||
|
|
@ -836,8 +880,8 @@ export class QqHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
let content = hasImages || hasReply
|
||||
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
|
|
@ -847,6 +891,7 @@ export class QqHarnessBridge {
|
|||
channel: 'qq',
|
||||
senderId: sender,
|
||||
senderName: message.kind === 'group' ? message.senderName : undefined,
|
||||
chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export class BatchInputManager {
|
|||
if (!batch) {
|
||||
if (!name) return { handled: false };
|
||||
if (!plainText) {
|
||||
return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片或文件后重试。'));
|
||||
return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片、文件或引用消息后重试。'));
|
||||
}
|
||||
if (name === 'send') {
|
||||
return result('no-batch', t('当前没有待提交的批量内容,请先发送 /batch。'));
|
||||
|
|
@ -91,7 +91,7 @@ export class BatchInputManager {
|
|||
}
|
||||
|
||||
if (!plainText && (batch.phase === 'collecting' || name)) {
|
||||
return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
|
||||
return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
|
||||
请继续发送文字,或使用 /send、/cancel。`), {
|
||||
count: batch.messages.length,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
|
|
@ -146,7 +146,7 @@ export class BatchInputManager {
|
|||
}
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
|
||||
return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
|
||||
请继续发送文字,或使用 /send、/cancel。`), {
|
||||
count: batch.messages.length,
|
||||
limit: BATCH_INPUT_LIMIT,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Shared by the Host and settings UI; keep this module browser-compatible.
|
||||
export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
|
||||
'channel', 'conversationType', 'senderId', 'senderName', 'conversationTitle', 'botId',
|
||||
'channel', 'conversationType', 'senderId', 'senderName', 'conversationTitle',
|
||||
'chatId', 'threadId', 'botId',
|
||||
]);
|
||||
|
||||
export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
|
||||
|
|
@ -38,7 +39,7 @@ const CHANNELS = new Set([
|
|||
]);
|
||||
const SOURCE_LIMITS = {
|
||||
channel: 16, conversationType: 6, senderId: 256, senderName: 256,
|
||||
conversationTitle: 256, botId: 128,
|
||||
conversationTitle: 256, chatId: 256, threadId: 256, botId: 128,
|
||||
};
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ function validateContextEnhancementScope(input) {
|
|||
throw invalidConfig('群聊和私聊开关必须是布尔值。');
|
||||
}
|
||||
if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
|
||||
throw invalidConfig('来源字段只能选择已定义的六个字段。');
|
||||
throw invalidConfig('来源字段只能选择已定义的八个字段。');
|
||||
}
|
||||
if (typeof guidance !== 'string' || guidance.length > CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH) {
|
||||
throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
|
||||
|
|
@ -150,7 +151,7 @@ function sourceString(value, field) {
|
|||
function sourceBlock(snapshot, sourceFactory) {
|
||||
const { fields } = snapshot.config;
|
||||
const needsSource = fields.some((field) => [
|
||||
'channel', 'senderId', 'senderName', 'conversationTitle',
|
||||
'channel', 'senderId', 'senderName', 'conversationTitle', 'chatId', 'threadId',
|
||||
].includes(field));
|
||||
const source = needsSource ? sourceFactory?.() : null;
|
||||
const projected = {};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import {
|
|||
appendInboundFilesToPrompt,
|
||||
InboundFileError,
|
||||
} from './inbound-file.mjs';
|
||||
import {
|
||||
IMAGE_FILE_FALLBACK_PROMPT,
|
||||
contentWithoutImages,
|
||||
imageFileSourcesFromContent,
|
||||
isModelImageRejection,
|
||||
} from './image-prompt.mjs';
|
||||
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
||||
import { t } from './i18n.mjs';
|
||||
import { watchHarnessMux } from './harness-mux.mjs';
|
||||
|
|
@ -1242,6 +1248,31 @@ export class HarnessClient {
|
|||
return ownership ? { ownership, recovered: true } : null;
|
||||
}
|
||||
|
||||
/** Stage inbound file sources into the Session workspace via the Host executor. */
|
||||
async #stageWorkspaceFiles(sessionId, files, signal) {
|
||||
if (!this.#fileIngressExecutor) {
|
||||
throw new InboundFileError(
|
||||
'inbound-file-ingress-unavailable',
|
||||
'Harness file ingress is unavailable in this Host process.',
|
||||
);
|
||||
}
|
||||
const sessionList = await this.rpc(
|
||||
'session.list',
|
||||
{},
|
||||
30_000,
|
||||
{ signal },
|
||||
);
|
||||
const sessionWorkspace = sessionList?.items?.find(
|
||||
(item) => item?.sessionId === sessionId,
|
||||
)?.cwd;
|
||||
return this.#fileIngressExecutor({
|
||||
sessionId,
|
||||
workspace: sessionWorkspace,
|
||||
files,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async ask(sessionId, prompt, options = {}) {
|
||||
if (typeof options === 'number') options = { timeoutMs: options };
|
||||
const timeoutMs = options.timeoutMs ?? 600_000;
|
||||
|
|
@ -1298,7 +1329,7 @@ export class HarnessClient {
|
|||
let interactionTask = null;
|
||||
let artifactsDelivered = false;
|
||||
let deliveredArtifactCount = 0;
|
||||
let stagedInboundFiles = null;
|
||||
const stagedBatches = [];
|
||||
let promptAccepted = false;
|
||||
let turnFinished = false;
|
||||
|
||||
|
|
@ -1329,29 +1360,11 @@ export class HarnessClient {
|
|||
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
||||
|
||||
try {
|
||||
const basePrompt = prompt;
|
||||
if (inboundFiles.length > 0) {
|
||||
if (!this.#fileIngressExecutor) {
|
||||
throw new InboundFileError(
|
||||
'inbound-file-ingress-unavailable',
|
||||
'Harness file ingress is unavailable in this Host process.',
|
||||
);
|
||||
}
|
||||
const sessionList = await this.rpc(
|
||||
'session.list',
|
||||
{},
|
||||
30_000,
|
||||
{ signal },
|
||||
);
|
||||
const sessionWorkspace = sessionList?.items?.find(
|
||||
(item) => item?.sessionId === sessionId,
|
||||
)?.cwd;
|
||||
stagedInboundFiles = await this.#fileIngressExecutor({
|
||||
sessionId,
|
||||
workspace: sessionWorkspace,
|
||||
files: inboundFiles,
|
||||
signal,
|
||||
});
|
||||
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
|
||||
const staged = await this.#stageWorkspaceFiles(sessionId, inboundFiles, signal);
|
||||
stagedBatches.push(staged);
|
||||
prompt = appendInboundFilesToPrompt(prompt, staged);
|
||||
}
|
||||
if (interactionSignal) {
|
||||
let markOpen;
|
||||
|
|
@ -1378,12 +1391,47 @@ export class HarnessClient {
|
|||
if (!Array.isArray(content) || content.length === 0) {
|
||||
throw new TypeError('Harness prompt content is required');
|
||||
}
|
||||
await this.rpc('session.prompt', {
|
||||
const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const sendPrompt = (promptContent) => this.rpc('session.prompt', {
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content,
|
||||
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
content: promptContent,
|
||||
clientTimeZone,
|
||||
}, 30_000, { rpcId: promptRpcId, signal });
|
||||
try {
|
||||
await sendPrompt(content);
|
||||
} catch (error) {
|
||||
// The Host refuses image blocks for a non-vision model before any
|
||||
// durable user message exists. Re-deliver the same bytes the way
|
||||
// ordinary uploads (zip, documents) already travel — staged into the
|
||||
// Session workspace and named in a text manifest — then retry once
|
||||
// with a text-only prompt. The retry reuses promptRpcId so reply
|
||||
// tracking, control and interaction ownership stay bound to this ask.
|
||||
const imageSources = isModelImageRejection(error)
|
||||
? imageFileSourcesFromContent(content)
|
||||
: [];
|
||||
if (imageSources.length === 0) throw error;
|
||||
let stagedImages;
|
||||
try {
|
||||
stagedImages = await this.#stageWorkspaceFiles(sessionId, imageSources, signal);
|
||||
} catch (stagingError) {
|
||||
if (signal?.aborted) throw signal.reason ?? stagingError;
|
||||
console.warn(
|
||||
`[${this.#logPrefix}] unable to restage rejected images as workspace files:`,
|
||||
stagingError?.message ?? String(stagingError),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
stagedBatches.push(stagedImages);
|
||||
const baseContent = typeof basePrompt === 'string'
|
||||
? [{ type: 'text', text: basePrompt }]
|
||||
: basePrompt;
|
||||
const fallbackPrompt = appendInboundFilesToPrompt([
|
||||
...contentWithoutImages(baseContent),
|
||||
{ type: 'text', text: t(IMAGE_FILE_FALLBACK_PROMPT) },
|
||||
], { files: stagedBatches.flatMap((batch) => batch?.files ?? []) });
|
||||
await sendPrompt(fallbackPrompt);
|
||||
}
|
||||
promptAccepted = true;
|
||||
|
||||
try {
|
||||
|
|
@ -1441,10 +1489,14 @@ export class HarnessClient {
|
|||
throw turnStoppedError();
|
||||
}
|
||||
} finally {
|
||||
if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
|
||||
await stagedInboundFiles.cleanup().catch((error) => {
|
||||
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
|
||||
});
|
||||
if (!promptAccepted || turnFinished) {
|
||||
for (const staged of stagedBatches) {
|
||||
try {
|
||||
await staged?.cleanup?.();
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
closeArtifactConsumer();
|
||||
if (ownership) {
|
||||
|
|
|
|||
|
|
@ -246,8 +246,8 @@ export default {
|
|||
'/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
|
||||
'**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
|
||||
'**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
|
||||
'**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/workspace 工作区序号或绝对路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
|
||||
'**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/workspace <workspace index or absolute path>` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
|
||||
'**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/sessionlist --limit N` 或 `/sessions --limit N` — 仅列出当前工作区前 N 个会话\n`/workspace 工作区序号或绝对路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
|
||||
'**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/sessionlist --limit N` or `/sessions --limit N` — list only the first N sessions in the current workspace\n`/workspace <workspace index or absolute path>` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
|
||||
'**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**补全权限 · **6**帮助':
|
||||
'**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Complete permissions · **6** Help',
|
||||
'从下方下拉选择补充指令;最后一项可自定义输入。':
|
||||
|
|
|
|||
|
|
@ -123,10 +123,13 @@ export default {
|
|||
'/workspace 工作区序号或绝对路径 切换工作区':
|
||||
'/workspace <workspace index or absolute path> Switch workspace',
|
||||
'/workspacelist 列出工作区绝对路径': '/workspacelist List absolute workspace paths',
|
||||
'/ws、/wsl、/workspaces 工作区命令别名': '/ws, /wsl, /workspaces Workspace command aliases',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题':
|
||||
'/sessionlist [workspace index or absolute path] List session IDs and titles',
|
||||
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题':
|
||||
'/sessionlist or /sessions [workspace index or absolute path] List session IDs and titles',
|
||||
'/sessionlist --limit N 仅列出当前工作区前 N 个会话':
|
||||
'/sessionlist --limit N List only the first N sessions in the current workspace',
|
||||
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话':
|
||||
'/session <Session ID or workspace index> Bind this chat to the specified session',
|
||||
'/models 按序号列出所有可用模型': '/models List all available models by index',
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ export default {
|
|||
// workspace-command.mjs
|
||||
'用法:/session Session ID 或当前工作区序号(/session N)':
|
||||
'Usage: /session Session ID or the session index in the current Workspace (/session N)',
|
||||
'用法:\n/sessionlist 列出当前工作区会话\n/sessionlist 工作区序号 按 /workspacelist 序号列出会话\n/sessionlist 工作区绝对路径 列出指定工作区会话':
|
||||
'Usage:\n/sessionlist List Sessions in the current Workspace\n/sessionlist Workspace index List Sessions by /workspacelist index\n/sessionlist Workspace absolute path List Sessions in the given Workspace',
|
||||
'用法:\n/sessionlist 列出当前工作区会话\n/sessionlist --limit N 列出当前工作区前 N 个会话(N 为正整数)\n/sessionlist 工作区序号 按 /workspacelist 序号列出会话\n/sessionlist 工作区绝对路径 列出指定工作区会话':
|
||||
'Usage:\n/sessionlist List Sessions in the current Workspace\n/sessionlist --limit N List the first N Sessions in the current Workspace (N must be a positive integer)\n/sessionlist Workspace index List Sessions by /workspacelist index\n/sessionlist Workspace absolute path List Sessions in the given Workspace',
|
||||
'工作区必须是绝对路径。\n{usage}': 'The Workspace must be an absolute path.\n{usage}',
|
||||
'工作区路径包含不支持的字符或长度超过限制。\n{usage}':
|
||||
'The Workspace path contains unsupported characters or exceeds the length limit.\n{usage}',
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ export default {
|
|||
// image-prompt.mjs
|
||||
'当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。':
|
||||
'The current model does not support images. Use /models to list available models, switch with /model <number>, then resend.',
|
||||
'当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方文件清单)。请使用可用工具分析这些图片文件后回答,例如 run_code 或 pwsh 读取字节、解析元数据、调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。':
|
||||
'The current session model does not accept direct image input. The images sent by the user were saved into the workspace as files (see the file manifest below). Answer by analyzing those image files with the available tools — for example run_code or pwsh to read bytes, parse metadata, or call image-processing or OCR libraries — and do not assume you can see the images directly.',
|
||||
'图片超过宿主允许的大小,请压缩后重试。':
|
||||
'The image exceeds the size allowed by the host; compress it and try again.',
|
||||
'图片分辨率过高,请压缩后重试。':
|
||||
|
|
@ -157,15 +159,15 @@ export default {
|
|||
'当前聊天有正在运行的任务、待回答问题或待审批请求。\n请先完成当前交互或发送 /stop,再使用 /batch。':
|
||||
'This chat has a running task, unanswered question, or pending approval.\nFinish the current interaction or send /stop before using /batch.',
|
||||
'用法:/{command}(不带参数)': 'Usage: /{command} (without arguments)',
|
||||
'批量输入命令仅支持纯文字,请移除图片或文件后重试。':
|
||||
'Batch input commands support text only. Remove the image or file and try again.',
|
||||
'批量输入命令仅支持纯文字,请移除图片、文件或引用消息后重试。':
|
||||
'Batch input commands support text only. Remove the image, file, or quoted message and try again.',
|
||||
'当前没有待提交的批量内容,请先发送 /batch。':
|
||||
'There is no batch to submit. Send /batch first.',
|
||||
'当前没有正在进行的批量输入。': 'There is no active batch input.',
|
||||
'已进入批量输入模式,最多可发送 {limit} 条文字。\n完成后发送 /send,取消请发送 /cancel。':
|
||||
'Batch input started. You can send up to {limit} text messages.\nSend /send when finished or /cancel to cancel.',
|
||||
'批量输入模式目前仅支持文字,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
|
||||
'Batch input currently supports text only, so this message was not collected.\nContinue with text, or use /send or /cancel.',
|
||||
'批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
|
||||
'Batch input currently supports text only, not images, files, or quoted messages, so this message was not collected.\nContinue with text, or use /send or /cancel.',
|
||||
'当前批次正在提交,请勿重复发送 /send。':
|
||||
'The current batch is being submitted. Do not send /send again.',
|
||||
'批量内容已经提交,无法取消。\n如需停止当前任务,请发送 /stop。':
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
|
|||
|
||||
export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
|
||||
|
||||
/**
|
||||
* Model-facing guidance appended when the Host refuses image input for the
|
||||
* current model and the same images are re-delivered as workspace files.
|
||||
*/
|
||||
export const IMAGE_FILE_FALLBACK_PROMPT = '当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方文件清单)。请使用可用工具分析这些图片文件后回答,例如 run_code 或 pwsh 读取字节、解析元数据、调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。';
|
||||
|
||||
export class ImagePromptError extends Error {
|
||||
constructor(code, message, userMessage, options = {}) {
|
||||
super(message, options);
|
||||
|
|
@ -299,3 +305,48 @@ export function imagePromptDiagnostic(error) {
|
|||
export function imagePromptUserMessage(error) {
|
||||
return imagePromptDiagnostic(error)?.userMessage ?? null;
|
||||
}
|
||||
|
||||
const IMAGE_FILE_EXTENSIONS = new Map([
|
||||
['image/png', '.png'],
|
||||
['image/jpeg', '.jpg'],
|
||||
['image/gif', '.gif'],
|
||||
['image/webp', '.webp'],
|
||||
]);
|
||||
|
||||
const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
|
||||
|
||||
function imageStorageName(name, mediaType, index) {
|
||||
const extension = IMAGE_FILE_EXTENSIONS.get(mediaType) ?? '.img';
|
||||
const cleaned = safeName(name);
|
||||
if (cleaned && IMAGE_EXTENSION_PATTERN.test(cleaned)) return cleaned;
|
||||
return `${cleaned ?? `image-${index + 1}`}${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert already-admitted image content blocks into inbound file sources so
|
||||
* the same bytes can reach a non-vision model as workspace files — the path
|
||||
* ordinary uploads such as zip archives already take.
|
||||
*/
|
||||
export function imageFileSourcesFromContent(content) {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
.filter((part) => part?.type === 'image')
|
||||
.map((part, index) => ({
|
||||
name: imageStorageName(part.name, part.mediaType, index),
|
||||
...(typeof part.mediaType === 'string' && part.mediaType.trim()
|
||||
? { mediaType: part.mediaType.trim() }
|
||||
: {}),
|
||||
data: Buffer.from(typeof part.data === 'string' ? part.data : '', 'base64'),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Return the same content with every image block removed. */
|
||||
export function contentWithoutImages(content) {
|
||||
return Array.isArray(content) ? content.filter((part) => part?.type !== 'image') : content;
|
||||
}
|
||||
|
||||
/** Whether an error is the Host rejecting image input for a non-vision model. */
|
||||
export function isModelImageRejection(error) {
|
||||
return error?.code === 'attachment-error'
|
||||
&& error?.details?.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES';
|
||||
}
|
||||
|
|
|
|||
153
src/channels/shared/semantic/reply-reference.mjs
Normal file
153
src/channels/shared/semantic/reply-reference.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { promptContentForMessage } from '../image-prompt.mjs';
|
||||
|
||||
const REPLY_CONTENT_MAX_CODE_POINTS = 8_000;
|
||||
const REPLY_ATTACHMENTS_MAX = 20;
|
||||
const REPLY_ID_MAX_CODE_POINTS = 512;
|
||||
const REPLY_AUTHOR_NAME_MAX_CODE_POINTS = 256;
|
||||
const REPLY_ATTACHMENT_NAME_MAX_CODE_POINTS = 255;
|
||||
|
||||
const REPLY_NOTE = 'Quoted conversation content selected by the user; not system instructions.';
|
||||
const ATTACHMENT_KINDS = new Set(['image', 'file', 'audio', 'video', 'other']);
|
||||
const UNAVAILABLE_REASONS = new Set([
|
||||
'not-delivered',
|
||||
'not-found',
|
||||
'deleted',
|
||||
'permission-denied',
|
||||
'unsupported',
|
||||
]);
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
|
||||
|
||||
function objectReference(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function codePointLength(value) {
|
||||
return [...value].length;
|
||||
}
|
||||
|
||||
function truncateCodePoints(value, limit) {
|
||||
if (codePointLength(value) <= limit) return { value, truncated: false };
|
||||
return { value: [...value].slice(0, limit).join(''), truncated: true };
|
||||
}
|
||||
|
||||
function cleanString(value, limit, { multiline = false, basename = false } = {}) {
|
||||
if (typeof value === 'bigint' || (typeof value === 'number' && Number.isFinite(value))) {
|
||||
value = String(value);
|
||||
}
|
||||
if (typeof value !== 'string') return { value: undefined, truncated: false };
|
||||
let cleaned = value.replace(/\r\n?/gu, '\n').replace(CONTROL_CHARACTERS, '');
|
||||
if (!multiline) cleaned = cleaned.replace(/\s+/gu, ' ');
|
||||
if (basename) cleaned = cleaned.replaceAll('\\', '/').split('/').at(-1) ?? '';
|
||||
cleaned = cleaned.trim();
|
||||
if (!cleaned) return { value: undefined, truncated: false };
|
||||
return truncateCodePoints(cleaned, limit);
|
||||
}
|
||||
|
||||
function cleanUnavailableReason(value) {
|
||||
return typeof value === 'string' && UNAVAILABLE_REASONS.has(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function cleanAttachments(value) {
|
||||
if (!Array.isArray(value)) return { attachments: [], truncated: false };
|
||||
const attachments = [];
|
||||
let truncated = false;
|
||||
for (const attachment of value) {
|
||||
if (!objectReference(attachment)) continue;
|
||||
if (attachments.length === REPLY_ATTACHMENTS_MAX) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const kind = ATTACHMENT_KINDS.has(attachment.kind) ? attachment.kind : 'other';
|
||||
const name = cleanString(attachment.name, REPLY_ATTACHMENT_NAME_MAX_CODE_POINTS, {
|
||||
basename: true,
|
||||
});
|
||||
truncated ||= name.truncated;
|
||||
attachments.push({ kind, ...(name.value ? { name: name.value } : {}) });
|
||||
}
|
||||
return { attachments, truncated };
|
||||
}
|
||||
|
||||
function errorUnavailableReason(error) {
|
||||
const supplied = cleanUnavailableReason(error?.code);
|
||||
if (supplied) return supplied;
|
||||
const status = Number(error?.status ?? error?.statusCode ?? error?.response?.status);
|
||||
if (status === 401 || status === 403) return 'permission-denied';
|
||||
if (status === 404) return 'not-found';
|
||||
if (status === 410) return 'deleted';
|
||||
return 'not-delivered';
|
||||
}
|
||||
|
||||
function mergeDefined(base, loaded) {
|
||||
const merged = { ...base };
|
||||
for (const key of [
|
||||
'messageId', 'authorId', 'authorName', 'content', 'attachments', 'unavailableReason',
|
||||
]) {
|
||||
if (loaded[key] !== undefined) merged[key] = loaded[key];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function resolveReference(reference, signal) {
|
||||
if (typeof reference.load !== 'function') return reference;
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const loaded = await reference.load({ signal });
|
||||
signal?.throwIfAborted();
|
||||
if (loaded === null) return { ...reference, unavailableReason: 'not-found' };
|
||||
if (!objectReference(loaded)) {
|
||||
return { ...reference, unavailableReason: 'not-delivered' };
|
||||
}
|
||||
return mergeDefined(reference, loaded);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) signal.throwIfAborted();
|
||||
return { ...reference, unavailableReason: errorUnavailableReason(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReference(reference) {
|
||||
const messageId = cleanString(reference.messageId, REPLY_ID_MAX_CODE_POINTS);
|
||||
const authorId = cleanString(reference.authorId, REPLY_ID_MAX_CODE_POINTS);
|
||||
const authorName = cleanString(reference.authorName, REPLY_AUTHOR_NAME_MAX_CODE_POINTS);
|
||||
const content = cleanString(reference.content, REPLY_CONTENT_MAX_CODE_POINTS, { multiline: true });
|
||||
const { attachments, truncated: attachmentsTruncated } = cleanAttachments(reference.attachments);
|
||||
let unavailableReason = cleanUnavailableReason(reference.unavailableReason);
|
||||
if (!content.value && attachments.length === 0 && !unavailableReason) {
|
||||
unavailableReason = 'not-delivered';
|
||||
}
|
||||
return {
|
||||
note: REPLY_NOTE,
|
||||
...(messageId.value ? { messageId: messageId.value } : {}),
|
||||
...(authorId.value ? { authorId: authorId.value } : {}),
|
||||
...(authorName.value ? { authorName: authorName.value } : {}),
|
||||
...(content.value ? { content: content.value } : {}),
|
||||
attachments,
|
||||
...(unavailableReason ? { unavailableReason } : {}),
|
||||
truncated: messageId.truncated
|
||||
|| authorId.truncated
|
||||
|| authorName.truncated
|
||||
|| content.truncated
|
||||
|| attachmentsTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
function replyBlock(reference) {
|
||||
const json = JSON.stringify(reference).replace(/[<>&]/gu, (character) => ({
|
||||
'<': '\\u003c',
|
||||
'>': '\\u003e',
|
||||
'&': '\\u0026',
|
||||
})[character]);
|
||||
return `<dsh_im_reply_to>${json}</dsh_im_reply_to>`;
|
||||
}
|
||||
|
||||
export function hasReplyReference(message) {
|
||||
return objectReference(message?.replyTo);
|
||||
}
|
||||
|
||||
export async function promptContentForInboundMessage(message, { signal } = {}) {
|
||||
if (!hasReplyReference(message)) {
|
||||
return promptContentForMessage(message, { signal });
|
||||
}
|
||||
const reference = normalizeReference(await resolveReference(message.replyTo, signal));
|
||||
const currentContent = await promptContentForMessage(message, { signal });
|
||||
return [{ type: 'text', text: replyBlock(reference) }, ...currentContent];
|
||||
}
|
||||
104
src/channels/shared/session-reply-recovery.mjs
Normal file
104
src/channels/shared/session-reply-recovery.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
function assistantText(event) {
|
||||
if (event?.type !== 'assistant/message'
|
||||
|| !Number.isSafeInteger(event.data?.turn)
|
||||
|| event.data.turn < 0
|
||||
|| !Array.isArray(event.data?.message?.content)) return null;
|
||||
const text = event.data.message.content
|
||||
.flatMap((block) => (block?.type === 'text' && typeof block.text === 'string'
|
||||
? [block.text]
|
||||
: []))
|
||||
.join('\n')
|
||||
.trim();
|
||||
return text ? { turn: event.data.turn, time: event.time, text } : null;
|
||||
}
|
||||
|
||||
function completedAssistantTurns(events) {
|
||||
const starts = new Map();
|
||||
const assistants = new Map();
|
||||
const completed = [];
|
||||
for (const event of [...events].sort((left, right) => left.seq - right.seq)) {
|
||||
if (event?.type === 'turn/start' && Number.isSafeInteger(event.data?.turn)) {
|
||||
starts.set(event.data.turn, event.time);
|
||||
}
|
||||
const assistant = assistantText(event);
|
||||
if (assistant) assistants.set(assistant.turn, assistant);
|
||||
if (event?.type !== 'turn/end' || !Number.isSafeInteger(event.data?.turn)) continue;
|
||||
const final = assistants.get(event.data.turn);
|
||||
assistants.delete(event.data.turn);
|
||||
if ((event.data?.reason?.kind ?? event.data?.reason) !== 'completed' || !final) continue;
|
||||
completed.push({
|
||||
...final,
|
||||
startedAt: starts.get(event.data.turn),
|
||||
completedAt: event.time,
|
||||
});
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
function matchingAssistantText(events, quotedAt, toleranceMs) {
|
||||
const candidates = completedAssistantTurns(events).filter((entry) => {
|
||||
if (Number.isFinite(entry.time) && Math.abs(entry.time - quotedAt) <= toleranceMs) {
|
||||
return true;
|
||||
}
|
||||
return Number.isFinite(entry.startedAt) && Number.isFinite(entry.completedAt)
|
||||
&& quotedAt >= entry.startedAt - toleranceMs
|
||||
&& quotedAt <= entry.completedAt + toleranceMs;
|
||||
});
|
||||
return candidates.length === 1 ? candidates[0].text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover one completed assistant answer near a provider message timestamp.
|
||||
*
|
||||
* @param {object} options Recovery inputs.
|
||||
* @param {{readHistory: Function}} options.session Bound Harness Session handle.
|
||||
* @param {number} options.quotedAt Provider message timestamp in milliseconds.
|
||||
* @param {AbortSignal} [options.signal] Caller cancellation signal.
|
||||
* @param {number} [options.pageSize=100] History events requested per page.
|
||||
* @param {number} [options.maxPages=3] Maximum history pages to inspect.
|
||||
* @param {number} [options.timeoutMs=5000] Total history read deadline.
|
||||
* @param {number} [options.toleranceMs=15000] Provider/session clock tolerance.
|
||||
* @returns {Promise<string|null>} The unique matching answer, or null.
|
||||
*/
|
||||
export async function recoverAssistantTextByTimestamp({
|
||||
session,
|
||||
quotedAt,
|
||||
signal: callerSignal,
|
||||
pageSize = 100,
|
||||
maxPages = 3,
|
||||
timeoutMs = 5_000,
|
||||
toleranceMs = 15_000,
|
||||
} = {}) {
|
||||
if (typeof session?.readHistory !== 'function' || !Number.isFinite(quotedAt)) return null;
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const events = new Map();
|
||||
let beforeSeq;
|
||||
for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
|
||||
signal.throwIfAborted();
|
||||
const page = await session.readHistory({
|
||||
maxMessages: pageSize,
|
||||
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
||||
timeoutMs: Math.max(1, deadline - Date.now()),
|
||||
signal,
|
||||
});
|
||||
if (!page || !Array.isArray(page.events) || typeof page.hasMore !== 'boolean') return null;
|
||||
let oldestSeq = beforeSeq ?? Infinity;
|
||||
let oldestTime = Infinity;
|
||||
for (const entry of page.events) {
|
||||
const event = entry?.event;
|
||||
if (!event || !Number.isSafeInteger(event.seq) || event.seq < 0) continue;
|
||||
events.set(event.seq, event);
|
||||
oldestSeq = Math.min(oldestSeq, event.seq);
|
||||
if (Number.isFinite(event.time)) oldestTime = Math.min(oldestTime, event.time);
|
||||
}
|
||||
const text = matchingAssistantText([...events.values()], quotedAt, toleranceMs);
|
||||
const passedTarget = oldestTime <= quotedAt - toleranceMs;
|
||||
if (text && (passedTarget || !page.hasMore)) return text;
|
||||
if (!page.hasMore || passedTarget
|
||||
|| !Number.isFinite(oldestSeq) || oldestSeq === beforeSeq) break;
|
||||
beforeSeq = oldestSeq;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ const CSI_SEQUENCE = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu;
|
|||
const ESC_SEQUENCE = /\u001b[@-_]/gu;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
|
||||
const DIRECTIONAL_CONTROL = /[\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
|
||||
const INJECTED_CONTEXT_PREFIX = /^(?:<dsh_im_source>[\s\S]*?<\/dsh_im_source>\s*)?(?:<dsh_im_source_guidance>[\s\S]*?<\/dsh_im_source_guidance>\s*)?/u;
|
||||
const INJECTED_CONTEXT_PREFIX = /^(?:<dsh_im_source>[\s\S]*?<\/dsh_im_source>\s*)?(?:<dsh_im_source_guidance>[\s\S]*?<\/dsh_im_source_guidance>\s*)?(?:<dsh_im_reply_to>[\s\S]*?<\/dsh_im_reply_to>\s*)?/u;
|
||||
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
|
||||
function cleanTitleText(input) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import {
|
|||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from './image-prompt.mjs';
|
||||
import {
|
||||
hasInboundFiles,
|
||||
|
|
@ -47,6 +46,10 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from './harness-question.mjs';
|
||||
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
hasReplyReference,
|
||||
promptContentForInboundMessage,
|
||||
} from './semantic/reply-reference.mjs';
|
||||
import {
|
||||
createDeliveryReceipt,
|
||||
createTextDeliveryBlock,
|
||||
|
|
@ -283,7 +286,8 @@ export class TextHarnessBridge {
|
|||
plainText: Boolean(text)
|
||||
&& normalized.plainText !== false
|
||||
&& !hasInboundImages(normalized)
|
||||
&& !hasInboundFiles(normalized),
|
||||
&& !hasInboundFiles(normalized)
|
||||
&& !hasReplyReference(normalized),
|
||||
});
|
||||
if (batch.handled) {
|
||||
if (batch.kind === 'submit') {
|
||||
|
|
@ -301,7 +305,8 @@ export class TextHarnessBridge {
|
|||
plainText: Boolean(text)
|
||||
&& normalized.plainText !== false
|
||||
&& !hasInboundImages(normalized)
|
||||
&& !hasInboundFiles(normalized),
|
||||
&& !hasInboundFiles(normalized)
|
||||
&& !hasReplyReference(normalized),
|
||||
});
|
||||
if (batch.handled) {
|
||||
return this.#finishLocalMessage(normalized, messageId, batch.message);
|
||||
|
|
@ -575,7 +580,8 @@ export class TextHarnessBridge {
|
|||
}
|
||||
const hasImages = hasInboundImages(message);
|
||||
const hasFiles = hasInboundFiles(message);
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
const hasReply = hasReplyReference(message);
|
||||
if (!text && !hasImages && !hasFiles && !hasReply) {
|
||||
await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -590,7 +596,9 @@ export class TextHarnessBridge {
|
|||
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
||||
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
||||
t('/workspacelist 列出工作区绝对路径'),
|
||||
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
||||
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
||||
t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
|
||||
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
||||
t('/models 按序号列出所有可用模型'),
|
||||
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
|
||||
|
|
@ -669,18 +677,21 @@ export class TextHarnessBridge {
|
|||
);
|
||||
}
|
||||
}
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
let content = hasImages || hasReply
|
||||
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
const originalContent = content ?? text;
|
||||
const contextSource = message.contextSource?.();
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: this.#descriptor.key,
|
||||
senderId,
|
||||
senderName: message.contextSource?.()?.senderName,
|
||||
conversationTitle: message.contextSource?.()?.conversationTitle,
|
||||
senderName: contextSource?.senderName,
|
||||
conversationTitle: contextSource?.conversationTitle,
|
||||
chatId: contextSource?.chatId ?? message.conversationId,
|
||||
threadId: contextSource?.threadId,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { isAbsolute, resolve } from 'node:path';
|
|||
import { t } from './i18n.mjs';
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
|
||||
const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
|
||||
const WORKSPACE_COMMAND = /^\/(?:workspace|ws)(?:\s+([\s\S]+))?$/i;
|
||||
const WORKSPACE_LIST_COMMAND = /^\/(?:workspacelist|workspaces|wsl)(?:\s+([\s\S]+))?$/i;
|
||||
const SESSION_LIST_COMMAND = /^\/(?:sessionlist|sessions)(?:\s+([\s\S]+))?$/i;
|
||||
const SESSION_BIND_PREFIX = /^\/session(?=$|\s)/i;
|
||||
const SESSION_BIND_COMMAND = /^\/session[ \t]+([^\s]+)$/i;
|
||||
|
|
@ -18,6 +18,7 @@ const SESSION_BIND_USAGE = '用法:/session Session ID 或当前工作区序
|
|||
const SESSION_LIST_USAGE = [
|
||||
'用法:',
|
||||
'/sessionlist 列出当前工作区会话',
|
||||
'/sessionlist --limit N 列出当前工作区前 N 个会话(N 为正整数)',
|
||||
'/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
|
||||
'/sessionlist 工作区绝对路径 列出指定工作区会话',
|
||||
].join('\n');
|
||||
|
|
@ -45,6 +46,20 @@ function validSessionId(value) {
|
|||
&& !UNSAFE_DISPLAY_TEXT.test(value);
|
||||
}
|
||||
|
||||
export function parseSessionListArgument(value) {
|
||||
const argument = typeof value === 'string' ? value.trim() : '';
|
||||
if (!argument) return { selector: '', limit: null };
|
||||
if (!argument.toLowerCase().startsWith('--limit')) {
|
||||
return { selector: argument, limit: null };
|
||||
}
|
||||
const match = /^--limit[ \t]+(\d+)$/iu.exec(argument);
|
||||
const limit = match ? Number(match[1]) : null;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1) {
|
||||
return { error: t(SESSION_LIST_USAGE) };
|
||||
}
|
||||
return { selector: '', limit };
|
||||
}
|
||||
|
||||
async function existingWorkspacePaths(values) {
|
||||
const checked = await Promise.all(values.map(async (value) => {
|
||||
const workspace = normalizedWorkspacePath(value);
|
||||
|
|
@ -235,12 +250,13 @@ async function currentSessionListWorkspace(harness) {
|
|||
}
|
||||
|
||||
async function runSessionListCommand(match, harness) {
|
||||
const request = parseSessionListArgument(match[1]);
|
||||
if (request.error) return commandResult(request.error);
|
||||
if (typeof harness?.listWorkspaceSessions !== 'function') {
|
||||
return commandResult(t('当前机器人暂不支持列出工作区会话。'));
|
||||
}
|
||||
const selector = match[1]?.trim() ?? '';
|
||||
try {
|
||||
const resolved = await resolveSessionListWorkspace(selector, harness);
|
||||
const resolved = await resolveSessionListWorkspace(request.selector, harness);
|
||||
if (resolved.error) return commandResult(resolved.error);
|
||||
const listed = await harness.listWorkspaceSessions(resolved.workspace);
|
||||
if (!listed || !Array.isArray(listed.sessions)) {
|
||||
|
|
@ -249,7 +265,10 @@ async function runSessionListCommand(match, harness) {
|
|||
harness.assertWorkspaceScope?.();
|
||||
const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
|
||||
const currentWorkspace = await currentSessionListWorkspace(harness);
|
||||
const message = sessionListMessage(workspace, listed.sessions, {
|
||||
const sessions = request.limit === null
|
||||
? listed.sessions
|
||||
: listed.sessions.slice(0, request.limit);
|
||||
const message = sessionListMessage(workspace, sessions, {
|
||||
currentWorkspace: workspace === currentWorkspace,
|
||||
});
|
||||
return commandResult(message, splitWorkspaceCommandMessage(message));
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ oauth_config:
|
|||
bot:
|
||||
- app_mentions:read
|
||||
- chat:write
|
||||
- channels:history
|
||||
- files:read
|
||||
- files:write
|
||||
- groups:history
|
||||
- im:history
|
||||
- mpim:history
|
||||
- reactions:write
|
||||
settings:
|
||||
event_subscriptions:
|
||||
|
|
|
|||
|
|
@ -246,6 +246,24 @@ export class SlackApi {
|
|||
return value.file;
|
||||
}
|
||||
|
||||
async getMessage({ channelId, messageTs, signal } = {}) {
|
||||
const timestamp = requiredString(messageTs, 'message timestamp');
|
||||
const value = await this.#request('conversations.history', {
|
||||
tokenKind: 'bot',
|
||||
signal,
|
||||
body: {
|
||||
channel: slackId(channelId, 'channel id'),
|
||||
oldest: timestamp,
|
||||
latest: timestamp,
|
||||
inclusive: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
return Array.isArray(value?.messages)
|
||||
? value.messages.find((message) => String(message?.ts ?? '') === timestamp) ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
postMessage({ channelId, text, threadTs, signal }) {
|
||||
return this.#request('chat.postMessage', {
|
||||
tokenKind: 'bot',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,58 @@ function slackFileUrl(file) {
|
|||
? file.url_private_download : file?.url_private;
|
||||
}
|
||||
|
||||
function slackReplyAttachment(file) {
|
||||
if (!file || typeof file !== 'object') return null;
|
||||
const mediaType = typeof file.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
||||
const kind = mediaType.startsWith('image/') ? 'image'
|
||||
: mediaType.startsWith('audio/') ? 'audio'
|
||||
: mediaType.startsWith('video/') ? 'video' : 'file';
|
||||
const name = typeof file.name === 'string' && file.name
|
||||
? file.name : typeof file.title === 'string' && file.title ? file.title : undefined;
|
||||
return { kind, ...(name ? { name } : {}) };
|
||||
}
|
||||
|
||||
function slackReplySnapshot(message, messageTs) {
|
||||
if (!message || String(message.ts ?? '') !== messageTs) return null;
|
||||
const authorId = typeof message.user === 'string' && message.user
|
||||
? message.user : typeof message.bot_id === 'string' && message.bot_id
|
||||
? message.bot_id : undefined;
|
||||
const authorName = typeof message.username === 'string' && message.username
|
||||
? message.username : undefined;
|
||||
return {
|
||||
messageId: messageTs,
|
||||
...(authorId ? { authorId } : {}),
|
||||
...(authorName ? { authorName } : {}),
|
||||
content: decodeSlackText(message.text ?? ''),
|
||||
attachments: Array.isArray(message.files)
|
||||
? message.files.map(slackReplyAttachment).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function slackThreadReplyReference(event, loadReply) {
|
||||
const messageTs = typeof event?.thread_ts === 'string' ? event.thread_ts : '';
|
||||
if (!messageTs || messageTs === String(event?.ts ?? '')) return undefined;
|
||||
return {
|
||||
messageId: messageTs,
|
||||
load: async ({ signal } = {}) => {
|
||||
try {
|
||||
const message = await loadReply({
|
||||
channelId: String(event.channel),
|
||||
messageTs,
|
||||
signal,
|
||||
});
|
||||
return slackReplySnapshot(message, messageTs);
|
||||
} catch (error) {
|
||||
if (error?.code === 'slack-missing-scope') {
|
||||
return { messageId: messageTs, unavailableReason: 'permission-denied' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function slackImageSource(file, loadFile) {
|
||||
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
||||
const url = slackFileUrl(file);
|
||||
|
|
@ -104,6 +156,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|||
loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
|
||||
loadFileStream = loadFile,
|
||||
loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
|
||||
loadReply = async () => { throw new Error('Slack reply loader is unavailable'); },
|
||||
} = {}) {
|
||||
const event = payload?.event;
|
||||
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
||||
|
|
@ -112,12 +165,17 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|||
if (!direct && !mentioned) return null;
|
||||
if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
|
||||
const threadTs = String(event.thread_ts ?? event.ts);
|
||||
const replyTo = slackThreadReplyReference(event, loadReply);
|
||||
return {
|
||||
messageId: String(payload.event_id),
|
||||
senderId: String(event.user),
|
||||
senderIsBot: String(event.user) === String(botUserId),
|
||||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
|
||||
contextSource: () => ({
|
||||
chatId: String(event.channel),
|
||||
threadId: event.thread_ts ? String(event.thread_ts) : undefined,
|
||||
}),
|
||||
content: stripBotMention(event.text ?? '', botUserId),
|
||||
plainText: !Array.isArray(event.files) || event.files.length === 0,
|
||||
images: Array.isArray(event.files)
|
||||
|
|
@ -126,6 +184,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|||
files: Array.isArray(event.files)
|
||||
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
|
||||
: [],
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
addressed: direct || mentioned,
|
||||
reactionTarget: {
|
||||
channelId: String(event.channel),
|
||||
|
|
@ -555,6 +614,7 @@ export class SlackRuntime {
|
|||
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
||||
loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
|
||||
loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
|
||||
loadReply: (options) => this.#api.getMessage(options),
|
||||
});
|
||||
const bridge = this.#bridge;
|
||||
if (message && bridge) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createEditableMessageStream, splitMessageText } from '../shared/editabl
|
|||
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
||||
import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
|
||||
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
|
||||
import { createTelegramHttpTransport } from './telegram-http.mjs';
|
||||
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
||||
|
|
@ -20,7 +21,10 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze([
|
|||
{ command: 'new', description: '开启一个全新会话' },
|
||||
{ command: 'compact', description: '压缩当前会话的较早上下文' },
|
||||
{ command: 'workspace', description: '切换工作区' },
|
||||
{ command: 'ws', description: '切换工作区' },
|
||||
{ command: 'workspacelist', description: '列出工作区绝对路径' },
|
||||
{ command: 'workspaces', description: '列出工作区绝对路径' },
|
||||
{ command: 'wsl', description: '列出工作区绝对路径' },
|
||||
{ command: 'sessionlist', description: '列出会话 ID 和标题' },
|
||||
{ command: 'sessions', description: '列出会话 ID 和标题' },
|
||||
{ command: 'session', description: '将当前聊天绑定到指定会话' },
|
||||
|
|
@ -138,11 +142,94 @@ function telegramFileSource(message, loadFile) {
|
|||
};
|
||||
}
|
||||
|
||||
function telegramReplyAttachment(kind, file, fallbackName) {
|
||||
if (!file || typeof file !== 'object') return null;
|
||||
const name = typeof file.file_name === 'string' && file.file_name
|
||||
? file.file_name : typeof fallbackName === 'string' && fallbackName
|
||||
? fallbackName : undefined;
|
||||
return { kind, ...(name ? { name } : {}) };
|
||||
}
|
||||
|
||||
function telegramReplyAttachments(message) {
|
||||
const attachments = [];
|
||||
if (Array.isArray(message?.photo) && message.photo.length > 0) {
|
||||
const largest = message.photo.reduce((best, candidate) => (
|
||||
photoScore(candidate) > photoScore(best) ? candidate : best
|
||||
));
|
||||
attachments.push(telegramReplyAttachment(
|
||||
'image',
|
||||
largest,
|
||||
`${largest.file_unique_id ?? largest.file_id ?? 'telegram-photo'}.jpg`,
|
||||
));
|
||||
} else if (message?.document) {
|
||||
attachments.push(telegramReplyAttachment(
|
||||
imageTypeForDocument(message.document) ? 'image' : 'file',
|
||||
message.document,
|
||||
));
|
||||
}
|
||||
for (const [field, kind] of [
|
||||
['audio', 'audio'],
|
||||
['voice', 'audio'],
|
||||
['video', 'video'],
|
||||
['video_note', 'video'],
|
||||
['animation', 'video'],
|
||||
]) {
|
||||
if (message?.[field]) attachments.push(telegramReplyAttachment(kind, message[field]));
|
||||
}
|
||||
if (message?.sticker) {
|
||||
attachments.push(telegramReplyAttachment(
|
||||
message.sticker.is_video === true ? 'video' : 'image',
|
||||
message.sticker,
|
||||
message.sticker.file_unique_id ?? message.sticker.file_id,
|
||||
));
|
||||
}
|
||||
return attachments.filter(Boolean);
|
||||
}
|
||||
|
||||
function telegramReplyReference(message, { quote, loadReplyContent } = {}) {
|
||||
if ((!message || typeof message !== 'object')
|
||||
&& (!quote || typeof quote !== 'object')) return undefined;
|
||||
const authorId = message?.from?.id === undefined ? undefined : String(message.from.id);
|
||||
const authorName = [message?.from?.first_name, message?.from?.last_name]
|
||||
.filter((value) => typeof value === 'string' && value.trim())
|
||||
.map((value) => value.trim()).join(' ') || (
|
||||
typeof message?.from?.username === 'string' && message.from.username
|
||||
? message.from.username : undefined
|
||||
);
|
||||
const content = typeof message?.text === 'string'
|
||||
? message.text : typeof message?.caption === 'string'
|
||||
? message.caption : typeof quote?.text === 'string' ? quote.text : '';
|
||||
const attachments = telegramReplyAttachments(message);
|
||||
const messageId = Number.isSafeInteger(message?.message_id)
|
||||
? String(message.message_id) : undefined;
|
||||
const createdAt = Number.isSafeInteger(message?.date) && message.date >= 0
|
||||
? message.date * 1_000 : undefined;
|
||||
const load = !content.trim() && attachments.length === 0
|
||||
&& typeof loadReplyContent === 'function'
|
||||
? ({ signal } = {}) => loadReplyContent({
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(createdAt === undefined ? {} : { createdAt }),
|
||||
}, { signal })
|
||||
: null;
|
||||
return {
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(authorId ? { authorId } : {}),
|
||||
...(authorName ? { authorName } : {}),
|
||||
...(content.trim() ? { content } : {}),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(load ? { load } : {}),
|
||||
...(!content.trim() && attachments.length === 0 && !load
|
||||
? { unavailableReason: 'not-delivered' }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTelegramUpdate(update, {
|
||||
botId,
|
||||
username,
|
||||
loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
|
||||
loadFileStream = loadFile,
|
||||
loadReplyContent,
|
||||
}) {
|
||||
const message = update?.message;
|
||||
const chatId = message?.chat?.id;
|
||||
|
|
@ -159,6 +246,21 @@ export function normalizeTelegramUpdate(update, {
|
|||
? message.message_thread_id : undefined;
|
||||
const image = telegramImageSource(message, loadFile);
|
||||
const file = telegramFileSource(message, loadFileStream);
|
||||
const conversationId = messageThreadId === undefined
|
||||
? String(chatId) : `${chatId}:${messageThreadId}`;
|
||||
const key = `${direct ? 'direct' : 'group'}:${conversationId}`;
|
||||
const replyTo = telegramReplyReference(
|
||||
message.reply_to_message ?? message.external_reply,
|
||||
{
|
||||
quote: message.quote,
|
||||
...(typeof loadReplyContent === 'function' ? {
|
||||
loadReplyContent: (reference, options) => loadReplyContent({
|
||||
conversationKey: key,
|
||||
...reference,
|
||||
}, options),
|
||||
} : {}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
messageId: String(update.update_id),
|
||||
senderId: String(senderId),
|
||||
|
|
@ -167,15 +269,17 @@ export function normalizeTelegramUpdate(update, {
|
|||
.filter((value) => typeof value === 'string' && value.trim())
|
||||
.map((value) => value.trim()).join(' ') || message.from?.username,
|
||||
conversationTitle: direct ? undefined : message.chat?.title,
|
||||
chatId: String(chatId),
|
||||
threadId: messageThreadId === undefined ? undefined : String(messageThreadId),
|
||||
}),
|
||||
senderIsBot: message.from?.is_bot === true,
|
||||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: messageThreadId === undefined
|
||||
? String(chatId) : `${chatId}:${messageThreadId}`,
|
||||
conversationId,
|
||||
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
||||
plainText: typeof message.text === 'string',
|
||||
images: image ? [image] : [],
|
||||
files: file ? [file] : [],
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
addressed,
|
||||
reactionTarget: { chatId, messageId },
|
||||
replyTarget: {
|
||||
|
|
@ -839,6 +943,21 @@ export class TelegramRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
async #loadReplyContent(reference, { signal } = {}) {
|
||||
const key = typeof reference?.conversationKey === 'string'
|
||||
? reference.conversationKey.trim() : '';
|
||||
const quotedAt = Number(reference?.createdAt);
|
||||
if (!key || !Number.isFinite(quotedAt)) {
|
||||
return { unavailableReason: 'not-delivered' };
|
||||
}
|
||||
const sessionId = this.#state.sessionFor(key);
|
||||
const session = typeof sessionId === 'string' && sessionId
|
||||
? this.#harness.workspaceSession?.(sessionId)
|
||||
: null;
|
||||
const text = await recoverAssistantTextByTimestamp({ session, quotedAt, signal });
|
||||
return text ? { content: text } : { unavailableReason: 'not-delivered' };
|
||||
}
|
||||
|
||||
async #poll(initialCursor, signal) {
|
||||
let cursor = initialCursor;
|
||||
while (!signal.aborted) {
|
||||
|
|
@ -863,6 +982,7 @@ export class TelegramRuntime {
|
|||
username: this.#config.username,
|
||||
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
||||
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
|
||||
loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
|
||||
});
|
||||
if (message) {
|
||||
void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import {
|
|||
ImagePromptError,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
hasInboundFiles,
|
||||
|
|
@ -42,6 +41,10 @@ import {
|
|||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
hasReplyReference,
|
||||
promptContentForInboundMessage,
|
||||
} from '../shared/semantic/reply-reference.mjs';
|
||||
import {
|
||||
createDeliveryReceipt,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
|
@ -70,7 +73,9 @@ function helpText() {
|
|||
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
||||
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
||||
t('/workspacelist 列出工作区绝对路径'),
|
||||
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
||||
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
||||
t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
|
||||
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
||||
t('/models 按序号列出所有可用模型'),
|
||||
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
|
||||
|
|
@ -108,8 +113,7 @@ function conversationKey(frame) {
|
|||
return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`;
|
||||
}
|
||||
|
||||
function messageText(frame) {
|
||||
const body = bodyOf(frame);
|
||||
function messageContentText(body) {
|
||||
let text = '';
|
||||
if (body.msgtype === 'text') {
|
||||
text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
|
||||
|
|
@ -122,6 +126,12 @@ function messageText(frame) {
|
|||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function messageText(frame) {
|
||||
const body = bodyOf(frame);
|
||||
const text = messageContentText(body);
|
||||
// Group callbacks retain the leading @bot mention that caused delivery.
|
||||
// It is routing metadata rather than part of the user's prompt or answer.
|
||||
return body.chattype === 'group'
|
||||
|
|
@ -145,6 +155,36 @@ function fileContents(frame) {
|
|||
: [];
|
||||
}
|
||||
|
||||
function quoteAttachments(quote) {
|
||||
if (quote?.msgtype === 'image') return [{ kind: 'image' }];
|
||||
if (quote?.msgtype === 'voice') return [{ kind: 'audio' }];
|
||||
if (quote?.msgtype === 'file') {
|
||||
const name = nonEmptyString(
|
||||
quote.file?.filename ?? quote.file?.file_name ?? quote.file?.name,
|
||||
);
|
||||
return [{ kind: 'file', ...(name ? { name } : {}) }];
|
||||
}
|
||||
if (quote?.msgtype !== 'mixed' || !Array.isArray(quote.mixed?.msg_item)) return [];
|
||||
return quote.mixed.msg_item
|
||||
.filter((item) => item?.msgtype === 'image')
|
||||
.map(() => ({ kind: 'image' }));
|
||||
}
|
||||
|
||||
function replyReferenceForBody(body) {
|
||||
const quote = body?.quote;
|
||||
if (!quote || typeof quote !== 'object') return null;
|
||||
const content = messageContentText(quote);
|
||||
const attachments = quoteAttachments(quote);
|
||||
const supported = ['text', 'image', 'mixed', 'voice', 'file'].includes(quote.msgtype);
|
||||
return {
|
||||
...(content ? { content } : {}),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(!content && attachments.length === 0
|
||||
? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function imageSource(client, image) {
|
||||
const url = nonEmptyString(image?.url);
|
||||
if (!url) return null;
|
||||
|
|
@ -200,10 +240,13 @@ function fileSource(client, file) {
|
|||
}
|
||||
|
||||
export function wecomInboundMessage(frame, client) {
|
||||
const body = bodyOf(frame);
|
||||
const replyTo = replyReferenceForBody(body);
|
||||
return {
|
||||
content: messageText(frame),
|
||||
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
||||
files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -604,7 +647,7 @@ export class WecomHarnessBridge {
|
|||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, commandText, {
|
||||
plainText: isNativeWecomText(frame),
|
||||
plainText: isNativeWecomText(frame) && !hasReplyReference(commandMessage),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
|
|
@ -934,6 +977,7 @@ export class WecomHarnessBridge {
|
|||
const text = message.content;
|
||||
const hasImages = hasInboundImages(message);
|
||||
const hasFiles = hasInboundFiles(message);
|
||||
const hasReply = hasReplyReference(message);
|
||||
const key = conversationKey(frame);
|
||||
let streamId = null;
|
||||
let streamStarted = false;
|
||||
|
|
@ -942,7 +986,7 @@ export class WecomHarnessBridge {
|
|||
let batchSettled = batchSubmission === null;
|
||||
let promptRecorded = false;
|
||||
try {
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
if (!text && !hasImages && !hasFiles && !hasReply) {
|
||||
await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
|
|
@ -1003,8 +1047,8 @@ export class WecomHarnessBridge {
|
|||
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
|
||||
}
|
||||
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
let content = hasImages || hasReply
|
||||
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
|
|
@ -1013,6 +1057,7 @@ export class WecomHarnessBridge {
|
|||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'wecom',
|
||||
senderId,
|
||||
chatId,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,67 @@
|
|||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import { weixinMessageTimestampMs } from './weixin-api.mjs';
|
||||
|
||||
const EMPTY_STATE = Object.freeze({
|
||||
version: 1,
|
||||
sessions: {},
|
||||
seenMessageIds: [],
|
||||
getUpdatesBuf: '',
|
||||
recentOutboundMessages: [],
|
||||
});
|
||||
|
||||
export const WEIXIN_RECENT_OUTBOUND_LIMIT = 200;
|
||||
export const WEIXIN_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
||||
export const WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
|
||||
export const WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
||||
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
||||
}
|
||||
|
||||
function providerMessageIds(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value
|
||||
.map((id) => (id === undefined || id === null ? null : nonEmptyString(String(id))))
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function truncateText(value) {
|
||||
const text = nonEmptyString(value);
|
||||
return text ? [...text].slice(0, WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT).join('') : null;
|
||||
}
|
||||
|
||||
function normalizeRecentOutboundMessage(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const toUserId = nonEmptyString(value.toUserId);
|
||||
const text = truncateText(value.text);
|
||||
const sentAt = timestamp(value.sentAt);
|
||||
const completedAt = timestamp(value.completedAt) ?? sentAt;
|
||||
if (!toUserId || !text || sentAt === null || completedAt === null) return null;
|
||||
return {
|
||||
toUserId,
|
||||
text,
|
||||
sentAt,
|
||||
completedAt: Math.max(sentAt, completedAt),
|
||||
providerMessageIds: providerMessageIds(value.providerMessageIds),
|
||||
};
|
||||
}
|
||||
|
||||
function recentOutboundMessages(value, now = Date.now()) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const cutoff = now - WEIXIN_RECENT_OUTBOUND_TTL_MS;
|
||||
return value
|
||||
.map(normalizeRecentOutboundMessage)
|
||||
.filter((entry) => entry && entry.completedAt >= cutoff)
|
||||
.slice(-WEIXIN_RECENT_OUTBOUND_LIMIT);
|
||||
}
|
||||
|
||||
function normalizeState(value) {
|
||||
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
||||
const sessions = {};
|
||||
|
|
@ -25,6 +79,7 @@ function normalizeState(value) {
|
|||
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
||||
: [],
|
||||
getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '',
|
||||
recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +145,61 @@ export class WeixinStateStore {
|
|||
await this.#persist();
|
||||
}
|
||||
|
||||
async rememberOutboundMessage({
|
||||
toUserId,
|
||||
text,
|
||||
sentAt = Date.now(),
|
||||
completedAt = Date.now(),
|
||||
providerMessageIds: messageIds = [],
|
||||
} = {}) {
|
||||
const entry = normalizeRecentOutboundMessage({
|
||||
toUserId,
|
||||
text,
|
||||
sentAt,
|
||||
completedAt,
|
||||
providerMessageIds: messageIds,
|
||||
});
|
||||
if (!entry) throw new TypeError('Invalid Weixin outbound message');
|
||||
this.#state.recentOutboundMessages = recentOutboundMessages([
|
||||
...this.#state.recentOutboundMessages,
|
||||
entry,
|
||||
]);
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
recentOutboundTextFor({
|
||||
toUserId,
|
||||
messageId,
|
||||
createTimeMs,
|
||||
updateTimeMs,
|
||||
now = Date.now(),
|
||||
} = {}) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
if (!recipient) return null;
|
||||
const active = recentOutboundMessages(this.#state.recentOutboundMessages, now)
|
||||
.filter((entry) => entry.toUserId === recipient);
|
||||
const quotedMessageId = messageId === undefined || messageId === null
|
||||
? null
|
||||
: nonEmptyString(String(messageId));
|
||||
if (quotedMessageId) {
|
||||
const exact = active.filter((entry) => entry.providerMessageIds.includes(quotedMessageId));
|
||||
if (exact.length === 1) return exact[0].text;
|
||||
if (exact.length > 1) return null;
|
||||
}
|
||||
const quotedTimes = [
|
||||
timestamp(createTimeMs),
|
||||
timestamp(updateTimeMs),
|
||||
weixinMessageTimestampMs(quotedMessageId, { now }),
|
||||
]
|
||||
.filter((value) => value !== null);
|
||||
if (quotedTimes.length === 0) return null;
|
||||
const candidates = active.filter((entry) => quotedTimes.some((quotedAt) => (
|
||||
quotedAt >= entry.sentAt - WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
||||
&& quotedAt <= entry.completedAt + WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
||||
)));
|
||||
return candidates.length === 1 ? candidates[0].text : null;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return structuredClone(this.#state);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
|||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
||||
const WEIXIN_CDN_UPLOAD_RETRIES = 3;
|
||||
const WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT = 22n;
|
||||
const WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
|
||||
const WEIXIN_MESSAGE_ID_MAX_FUTURE_MS = 24 * 60 * 60 * 1_000;
|
||||
const LOGIN_STATUSES = new Set([
|
||||
'wait',
|
||||
'scaned',
|
||||
|
|
@ -689,6 +692,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
const recipient = nonEmptyString(toUserId);
|
||||
const content = nonEmptyString(text);
|
||||
if (!recipient || !content) throw new TypeError('toUserId and text are required');
|
||||
const clientId = `dsh-weixin-${randomUUID()}`;
|
||||
const response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
|
|
@ -699,7 +703,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
msg: {
|
||||
from_user_id: '',
|
||||
to_user_id: recipient,
|
||||
client_id: `dsh-weixin-${randomUUID()}`,
|
||||
client_id: clientId,
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: [{ type: 1, text_item: { text: content } }],
|
||||
|
|
@ -717,7 +721,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
{ providerCode: sendRejection },
|
||||
);
|
||||
}
|
||||
return true;
|
||||
return {
|
||||
...(response && typeof response === 'object' ? response : {}),
|
||||
providerMessageIds: [clientId],
|
||||
};
|
||||
},
|
||||
|
||||
async sendFile(request) {
|
||||
|
|
@ -791,6 +798,83 @@ export function extractWeixinText(message) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function weixinReplyAttachment(item) {
|
||||
if (item?.type === 2 || (item?.image_item && typeof item.image_item === 'object')) {
|
||||
return { kind: 'image' };
|
||||
}
|
||||
if (item?.type === 3 || (item?.voice_item && typeof item.voice_item === 'object')) {
|
||||
return { kind: 'audio' };
|
||||
}
|
||||
if (item?.type === 4 || (item?.file_item && typeof item.file_item === 'object')) {
|
||||
const name = nonEmptyString(item?.file_item?.file_name);
|
||||
return { kind: 'file', ...(name ? { name } : {}) };
|
||||
}
|
||||
if (item?.type === 5 || (item?.video_item && typeof item.video_item === 'object')) {
|
||||
return { kind: 'video' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function weixinQuotedText(item) {
|
||||
const text = nonEmptyString(item?.text_item?.text);
|
||||
if (text) return text;
|
||||
return nonEmptyString(item?.voice_item?.text);
|
||||
}
|
||||
|
||||
/** Extract the one-level reply snapshot embedded in an inbound iLink message item. */
|
||||
export function extractWeixinReplyReference(message, { resolveContent, loadContent } = {}) {
|
||||
const container = (message?.item_list ?? []).find((item) => (
|
||||
item?.ref_msg && typeof item.ref_msg === 'object'
|
||||
));
|
||||
if (!container) return null;
|
||||
const ref = container.ref_msg;
|
||||
const quotedItem = ref.message_item && typeof ref.message_item === 'object'
|
||||
? ref.message_item
|
||||
: null;
|
||||
const quotedText = weixinQuotedText(quotedItem);
|
||||
let content = quotedText ?? nonEmptyString(ref.title);
|
||||
const attachment = weixinReplyAttachment(quotedItem);
|
||||
const messageId = quotedItem?.msg_id === undefined || quotedItem?.msg_id === null
|
||||
? null
|
||||
: nonEmptyString(String(quotedItem.msg_id));
|
||||
const referenceDetails = {
|
||||
messageId,
|
||||
createTimeMs: quotedItem?.create_time_ms ?? ref.create_time_ms,
|
||||
updateTimeMs: quotedItem?.update_time_ms ?? ref.update_time_ms,
|
||||
};
|
||||
if (!content && !attachment && typeof resolveContent === 'function') {
|
||||
content = nonEmptyString(resolveContent(referenceDetails));
|
||||
}
|
||||
const load = !content && !attachment && typeof loadContent === 'function'
|
||||
? (options) => loadContent(referenceDetails, options)
|
||||
: null;
|
||||
const knownType = quotedItem && [1, 2, 3, 4, 5, 8].includes(quotedItem.type);
|
||||
return {
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(content ? { content } : {}),
|
||||
...(attachment ? { attachments: [attachment] } : {}),
|
||||
...(load ? { load } : {}),
|
||||
...(!content && !attachment && !load
|
||||
? { unavailableReason: quotedItem && !knownType ? 'unsupported' : 'not-delivered' }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode the millisecond timestamp carried by current 64-bit iLink message IDs. */
|
||||
export function weixinMessageTimestampMs(messageId, { now = Date.now() } = {}) {
|
||||
const value = messageId === undefined || messageId === null ? '' : String(messageId).trim();
|
||||
if (!/^\d{16,20}$/u.test(value)) return null;
|
||||
try {
|
||||
const timestampMs = Number(BigInt(value) >> WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT);
|
||||
if (!Number.isSafeInteger(timestampMs)
|
||||
|| timestampMs < WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS
|
||||
|| timestampMs > now + WEIXIN_MESSAGE_ID_MAX_FUTURE_MS) return null;
|
||||
return timestampMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function weixinMessageId(message) {
|
||||
if (message?.message_id !== undefined && message.message_id !== null) {
|
||||
return String(message.message_id);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import {
|
|||
DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
||||
extractWeixinFiles,
|
||||
extractWeixinImages,
|
||||
extractWeixinReplyReference,
|
||||
extractWeixinText,
|
||||
splitWeixinText,
|
||||
weixinMessageId,
|
||||
weixinMessageTimestampMs,
|
||||
} from './weixin-api.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
|
|
@ -38,7 +40,6 @@ import {
|
|||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
hasInboundFiles,
|
||||
|
|
@ -47,10 +48,15 @@ import {
|
|||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
hasReplyReference,
|
||||
promptContentForInboundMessage,
|
||||
} from '../shared/semantic/reply-reference.mjs';
|
||||
import {
|
||||
createDeliveryReceipt,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
|
|
@ -67,6 +73,10 @@ const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处
|
|||
const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
|
||||
const TYPING_RETRY_DELAY_MS = 60_000;
|
||||
const WEIXIN_SEND_DIAGNOSTIC = Symbol('weixin-send-diagnostic');
|
||||
const WEIXIN_REPLY_HISTORY_PAGE_SIZE = 100;
|
||||
const WEIXIN_REPLY_HISTORY_MAX_PAGES = 3;
|
||||
const WEIXIN_REPLY_HISTORY_TIMEOUT_MS = 5_000;
|
||||
const WEIXIN_REPLY_HISTORY_MATCH_TOLERANCE_MS = 15_000;
|
||||
|
||||
const HELP_TEXT = () => [
|
||||
t('微信已连接 DeepSeek Harness。'),
|
||||
|
|
@ -77,7 +87,9 @@ const HELP_TEXT = () => [
|
|||
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
||||
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
||||
t('/workspacelist 列出工作区绝对路径'),
|
||||
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
||||
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
||||
t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
|
||||
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
||||
t('/models 按序号列出所有可用模型'),
|
||||
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
|
||||
|
|
@ -182,7 +194,20 @@ function weixinSendFailureOptions(error) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function weixinInboundMessage(message, api) {
|
||||
export function weixinInboundMessage(message, api, state, { loadReplyContent } = {}) {
|
||||
const toUserId = nonEmptyString(message?.from_user_id);
|
||||
const replyTo = extractWeixinReplyReference(message, {
|
||||
resolveContent: (reference) => state?.recentOutboundTextFor?.({
|
||||
toUserId,
|
||||
...reference,
|
||||
}),
|
||||
...(typeof loadReplyContent === 'function' ? {
|
||||
loadContent: (reference, options) => loadReplyContent({
|
||||
toUserId,
|
||||
...reference,
|
||||
}, options),
|
||||
} : {}),
|
||||
});
|
||||
return {
|
||||
content: extractWeixinText(message) ?? '',
|
||||
images: typeof api?.inboundImages === 'function'
|
||||
|
|
@ -191,6 +216,7 @@ export function weixinInboundMessage(message, api) {
|
|||
files: typeof api?.inboundFiles === 'function'
|
||||
? api.inboundFiles(message)
|
||||
: extractWeixinFiles(message),
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -375,7 +401,8 @@ export class WeixinHarnessBridge {
|
|||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||||
: this.#batchInputs.handle(key, commandText, {
|
||||
plainText: isNativeWeixinText(message),
|
||||
plainText: isNativeWeixinText(message)
|
||||
&& !extractWeixinReplyReference(message),
|
||||
});
|
||||
if (result.handled) {
|
||||
if (result.kind === 'submit') {
|
||||
|
|
@ -489,7 +516,7 @@ export class WeixinHarnessBridge {
|
|||
batchSubmission = null,
|
||||
} = {}) {
|
||||
const preparedMessage = prefetchInboundFiles(
|
||||
weixinInboundMessage(message, this.#api),
|
||||
this.#inboundMessage(message),
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
|
|
@ -508,6 +535,54 @@ export class WeixinHarnessBridge {
|
|||
return current;
|
||||
}
|
||||
|
||||
#inboundMessage(message) {
|
||||
return weixinInboundMessage(message, this.#api, this.#state, {
|
||||
loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
|
||||
});
|
||||
}
|
||||
|
||||
async #loadReplyContent(reference, { signal: callerSignal } = {}) {
|
||||
const indexed = this.#state.recentOutboundTextFor?.(reference);
|
||||
if (indexed) return { content: indexed };
|
||||
const quotedAt = [
|
||||
weixinMessageTimestampMs(reference?.messageId),
|
||||
Number(reference?.createTimeMs),
|
||||
Number(reference?.updateTimeMs),
|
||||
].find(Number.isSafeInteger);
|
||||
if (quotedAt === undefined) return { unavailableReason: 'not-delivered' };
|
||||
const sender = nonEmptyString(reference?.toUserId);
|
||||
const sessionId = sender ? this.#state.sessionFor(conversationKey(sender)) : null;
|
||||
const session = typeof sessionId === 'string' && sessionId
|
||||
? this.#harness.workspaceSession?.(sessionId)
|
||||
: null;
|
||||
if (typeof session?.readHistory !== 'function') {
|
||||
return { unavailableReason: 'not-delivered' };
|
||||
}
|
||||
const text = await recoverAssistantTextByTimestamp({
|
||||
session,
|
||||
quotedAt,
|
||||
signal: callerSignal,
|
||||
pageSize: WEIXIN_REPLY_HISTORY_PAGE_SIZE,
|
||||
maxPages: WEIXIN_REPLY_HISTORY_MAX_PAGES,
|
||||
timeoutMs: WEIXIN_REPLY_HISTORY_TIMEOUT_MS,
|
||||
toleranceMs: WEIXIN_REPLY_HISTORY_MATCH_TOLERANCE_MS,
|
||||
});
|
||||
if (!text) return { unavailableReason: 'not-delivered' };
|
||||
const messageId = nonEmptyString(reference?.messageId);
|
||||
try {
|
||||
await this.#state.rememberOutboundMessage?.({
|
||||
toUserId: sender,
|
||||
text,
|
||||
sentAt: quotedAt,
|
||||
completedAt: quotedAt,
|
||||
providerMessageIds: messageId ? [messageId] : [],
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-weixin] failed to remember a recovered quote:', error);
|
||||
}
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
#finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
|
||||
let task;
|
||||
task = Promise.resolve().then(async () => {
|
||||
|
|
@ -644,11 +719,12 @@ export class WeixinHarnessBridge {
|
|||
let batchSettled = batchSubmission === null;
|
||||
let promptRecorded = false;
|
||||
try {
|
||||
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
|
||||
const promptMessage = preparedMessage ?? this.#inboundMessage(message);
|
||||
const text = promptMessage.content;
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
const hasFiles = hasInboundFiles(promptMessage);
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
const hasReply = hasReplyReference(promptMessage);
|
||||
if (!text && !hasImages && !hasFiles && !hasReply) {
|
||||
await this.#send(sender, t('目前支持文字、图片、文件,以及微信已转成文字的语音消息。'), contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
|
|
@ -701,8 +777,8 @@ export class WeixinHarnessBridge {
|
|||
let artifacts = [];
|
||||
await this.#startTyping(sender, contextToken);
|
||||
try {
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
let content = hasImages || hasReply
|
||||
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
|
|
@ -711,6 +787,7 @@ export class WeixinHarnessBridge {
|
|||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'weixin',
|
||||
senderId: sender,
|
||||
chatId: sender,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
|
|
@ -1154,6 +1231,7 @@ export class WeixinHarnessBridge {
|
|||
const chunks = splitWeixinText(text, this.#maxMessageChars);
|
||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
||||
const chunk = chunks[chunkIndex];
|
||||
const sentAt = Date.now();
|
||||
try {
|
||||
const result = await this.#api.sendText({
|
||||
baseUrl: this.#baseUrl,
|
||||
|
|
@ -1164,7 +1242,19 @@ export class WeixinHarnessBridge {
|
|||
runId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
providerMessageIds.push(...providerMessageIdsFor(result));
|
||||
const chunkMessageIds = providerMessageIdsFor(result);
|
||||
providerMessageIds.push(...chunkMessageIds);
|
||||
try {
|
||||
await this.#state.rememberOutboundMessage?.({
|
||||
toUserId,
|
||||
text: chunk,
|
||||
sentAt,
|
||||
completedAt: Date.now(),
|
||||
providerMessageIds: chunkMessageIds,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-weixin] failed to remember an outbound message:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
throw weixinSendError(error, {
|
||||
baseUrl: this.#baseUrl,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError } from './weixin-api.mjs';
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
|
||||
import { providerMessageIdsFor } from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
connectionTestTarget,
|
||||
connectionTestTargetUnavailable,
|
||||
|
|
@ -324,9 +325,7 @@ export class WeixinRuntime {
|
|||
if (!this.#status.ready || !this.#abortController) {
|
||||
throw new Error('Weixin runtime is not connected');
|
||||
}
|
||||
await this.#api.sendText({
|
||||
baseUrl: this.#config.baseUrl,
|
||||
token: this.#token,
|
||||
await this.#sendTrackedText({
|
||||
toUserId,
|
||||
text,
|
||||
signal: this.#abortController.signal,
|
||||
|
|
@ -334,6 +333,29 @@ export class WeixinRuntime {
|
|||
return { sent: true };
|
||||
}
|
||||
|
||||
async #sendTrackedText({ toUserId, text, signal }) {
|
||||
const sentAt = Date.now();
|
||||
const result = await this.#api.sendText({
|
||||
baseUrl: this.#config.baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
text,
|
||||
signal,
|
||||
});
|
||||
try {
|
||||
await this.#state.rememberOutboundMessage?.({
|
||||
toUserId,
|
||||
text,
|
||||
sentAt,
|
||||
completedAt: Date.now(),
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-weixin] failed to remember an outbound message:', error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
const toUserId = typeof target?.route?.toUserId === 'string'
|
||||
? target.route.toUserId.trim() : '';
|
||||
|
|
@ -348,9 +370,7 @@ export class WeixinRuntime {
|
|||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await this.#api.sendText({
|
||||
baseUrl: this.#config.baseUrl,
|
||||
token: this.#token,
|
||||
await this.#sendTrackedText({
|
||||
toUserId,
|
||||
text,
|
||||
signal: signal ?? this.#abortController.signal,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,60 @@ function messageText(content) {
|
|||
?? '';
|
||||
}
|
||||
|
||||
function whatsappReplyAttachment(kind, media, fallbackName) {
|
||||
if (!media || typeof media !== 'object') return null;
|
||||
const name = typeof media.fileName === 'string' && media.fileName
|
||||
? media.fileName : typeof fallbackName === 'string' && fallbackName
|
||||
? fallbackName : undefined;
|
||||
return { kind, ...(name ? { name } : {}) };
|
||||
}
|
||||
|
||||
function whatsappReplyAttachments(content) {
|
||||
const attachments = [];
|
||||
if (content?.imageMessage) {
|
||||
attachments.push(whatsappReplyAttachment('image', content.imageMessage));
|
||||
}
|
||||
if (content?.documentMessage) {
|
||||
const mediaType = typeof content.documentMessage.mimetype === 'string'
|
||||
? content.documentMessage.mimetype.toLowerCase() : '';
|
||||
attachments.push(whatsappReplyAttachment(
|
||||
mediaType.startsWith('image/') ? 'image' : 'file',
|
||||
content.documentMessage,
|
||||
));
|
||||
}
|
||||
if (content?.audioMessage) {
|
||||
attachments.push(whatsappReplyAttachment('audio', content.audioMessage));
|
||||
}
|
||||
if (content?.videoMessage) {
|
||||
attachments.push(whatsappReplyAttachment('video', content.videoMessage));
|
||||
}
|
||||
if (content?.stickerMessage) {
|
||||
const mediaType = typeof content.stickerMessage.mimetype === 'string'
|
||||
? content.stickerMessage.mimetype.toLowerCase() : '';
|
||||
attachments.push(whatsappReplyAttachment(
|
||||
mediaType.startsWith('video/') || content.stickerMessage.isAnimated === true
|
||||
? 'video' : 'image',
|
||||
content.stickerMessage,
|
||||
));
|
||||
}
|
||||
return attachments.filter(Boolean);
|
||||
}
|
||||
|
||||
function whatsappReplyReference(context) {
|
||||
if (!context?.quotedMessage || typeof context.quotedMessage !== 'object') return undefined;
|
||||
const content = normalizeMessageContent(context.quotedMessage);
|
||||
const messageId = typeof context.stanzaId === 'string' && context.stanzaId
|
||||
? context.stanzaId : undefined;
|
||||
const authorId = typeof context.participant === 'string' && context.participant
|
||||
? context.participant : undefined;
|
||||
return {
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(authorId ? { authorId } : {}),
|
||||
content: messageText(content),
|
||||
attachments: whatsappReplyAttachments(content),
|
||||
};
|
||||
}
|
||||
|
||||
function mediaSize(value) {
|
||||
if (Number.isSafeInteger(value) && value >= 0) return value;
|
||||
let converted;
|
||||
|
|
@ -260,6 +314,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|||
&& areJidsSameUser(context.participant, accountJid);
|
||||
const image = whatsappImageSource(message, content, download, { viewOnce });
|
||||
const file = whatsappFileSource(message, content, download);
|
||||
const replyTo = whatsappReplyReference(context);
|
||||
return {
|
||||
messageId: `${remoteJid}:${messageId}`,
|
||||
providerMessageId: messageId,
|
||||
|
|
@ -274,6 +329,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|||
|| typeof content?.extendedTextMessage?.text === 'string',
|
||||
images: image ? [image] : [],
|
||||
files: file ? [file] : [],
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
addressed: !group || fromMe || mentioned || replyToSelf,
|
||||
selfChat,
|
||||
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue