mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
fix: preserve quoted message context across channels
This commit is contained in:
parent
c2be2389b0
commit
94e114b9b3
46 changed files with 4242 additions and 321 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,
|
||||
|
|
@ -185,11 +190,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 +298,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 +337,7 @@ export function dingtalkInboundMessage(message, {
|
|||
});
|
||||
},
|
||||
}] : [],
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -465,11 +561,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 +625,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 +871,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 +890,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 +1069,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 +1123,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;
|
||||
|
|
@ -1018,7 +1149,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 +1189,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 +1205,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,
|
||||
|
|
@ -626,7 +629,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') {
|
||||
|
|
@ -1032,11 +1037,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;
|
||||
}
|
||||
|
|
@ -3289,8 +3295,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;
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -145,6 +148,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 +230,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 +533,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 +826,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 +878,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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -157,15 +157,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。':
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -669,8 +675,8 @@ 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;
|
||||
|
|
|
|||
|
|
@ -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,6 +165,7 @@ 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),
|
||||
|
|
@ -126,6 +180,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 +610,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';
|
||||
|
|
@ -138,11 +139,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 +243,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),
|
||||
|
|
@ -170,12 +269,12 @@ export function normalizeTelegramUpdate(update, {
|
|||
}),
|
||||
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 +938,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 +977,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';
|
||||
|
|
@ -108,8 +111,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 +124,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 +153,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 +238,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 +645,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 +975,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 +984,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 +1045,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;
|
||||
|
|
|
|||
|
|
@ -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。'),
|
||||
|
|
@ -182,7 +192,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 +214,7 @@ export function weixinInboundMessage(message, api) {
|
|||
files: typeof api?.inboundFiles === 'function'
|
||||
? api.inboundFiles(message)
|
||||
: extractWeixinFiles(message),
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -375,7 +399,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 +514,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 +533,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 +717,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 +775,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;
|
||||
|
|
@ -1154,6 +1228,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 +1239,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