@xmanrui/dsh-im 4.5.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +4 -1
- package/lib/index.js +247 -239
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +166 -20
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
- package/src/channels/dingtalk/state-store.mjs +98 -0
- package/src/channels/discord/discord-api.mjs +7 -0
- package/src/channels/discord/discord-runtime.mjs +97 -2
- package/src/channels/feishu/bridge.mjs +11 -5
- package/src/channels/feishu/message-utils.mjs +229 -0
- package/src/channels/qq/qq-bridge.mjs +48 -6
- package/src/channels/shared/batch-input.mjs +3 -3
- package/src/channels/shared/harness-client.mjs +82 -30
- package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
- package/src/channels/shared/image-prompt.mjs +51 -0
- package/src/channels/shared/semantic/reply-reference.mjs +153 -0
- package/src/channels/shared/session-reply-recovery.mjs +104 -0
- package/src/channels/shared/session-title.mjs +1 -1
- package/src/channels/shared/text-harness-bridge.mjs +12 -6
- package/src/channels/slack/manifest.mjs +3 -0
- package/src/channels/slack/slack-api.mjs +18 -0
- package/src/channels/slack/slack-runtime.mjs +56 -0
- package/src/channels/telegram/telegram-runtime.mjs +117 -2
- package/src/channels/wecom/wecom-bridge.mjs +49 -7
- package/src/channels/weixin/state-store.mjs +110 -0
- package/src/channels/weixin/weixin-api.mjs +86 -2
- package/src/channels/weixin/weixin-bridge.mjs +96 -9
- package/src/channels/weixin/weixin-runtime.mjs +26 -6
- package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
package/package.json
CHANGED
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
hasInboundImages,
|
|
40
40
|
imagePromptDiagnostic,
|
|
41
41
|
imagePromptUserMessage,
|
|
42
|
-
promptContentForMessage,
|
|
43
42
|
} from '../shared/image-prompt.mjs';
|
|
44
43
|
import {
|
|
45
44
|
hasInboundFiles,
|
|
@@ -48,10 +47,16 @@ import {
|
|
|
48
47
|
} from '../shared/inbound-file.mjs';
|
|
49
48
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
50
49
|
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
|
50
|
+
import {
|
|
51
|
+
hasReplyReference,
|
|
52
|
+
promptContentForInboundMessage,
|
|
53
|
+
} from '../shared/semantic/reply-reference.mjs';
|
|
51
54
|
import {
|
|
52
55
|
createDeliveryReceipt,
|
|
53
56
|
providerMessageIdsFor,
|
|
54
57
|
} from '../shared/semantic/delivery.mjs';
|
|
58
|
+
import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
|
|
59
|
+
import { DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS } from './state-store.mjs';
|
|
55
60
|
import {
|
|
56
61
|
channelDeliveryFailure,
|
|
57
62
|
clearLastMessageFailure,
|
|
@@ -185,11 +190,95 @@ function downloadCodeFor(value) {
|
|
|
185
190
|
return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
|
|
186
191
|
}
|
|
187
192
|
|
|
193
|
+
function dingtalkTimestampMs(value) {
|
|
194
|
+
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
195
|
+
if (!Number.isFinite(number) || number < 0) return null;
|
|
196
|
+
return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function usefulReplyText(value) {
|
|
200
|
+
const text = nonEmptyString(value);
|
|
201
|
+
return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function dingtalkReplyReference(message, options) {
|
|
205
|
+
const replyEnvelope = message?.text;
|
|
206
|
+
if (replyEnvelope?.isReplyMsg !== true) return null;
|
|
207
|
+
const replied = replyEnvelope?.repliedMsg;
|
|
208
|
+
if (!replied || typeof replied !== 'object') {
|
|
209
|
+
return { unavailableReason: 'not-delivered' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
|
|
213
|
+
const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
|
|
214
|
+
const pseudoMessage = {
|
|
215
|
+
msgtype,
|
|
216
|
+
text: {
|
|
217
|
+
content: nonEmptyString(repliedContent.text)
|
|
218
|
+
?? (typeof replied.content === 'string' ? replied.content : ''),
|
|
219
|
+
},
|
|
220
|
+
content: repliedContent,
|
|
221
|
+
};
|
|
222
|
+
const normalized = dingtalkInboundMessage(pseudoMessage, options);
|
|
223
|
+
let attachments = [];
|
|
224
|
+
if (msgtype === 'picture') {
|
|
225
|
+
attachments = [{ kind: 'image' }];
|
|
226
|
+
} else if (msgtype === 'file') {
|
|
227
|
+
const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
|
|
228
|
+
attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
|
|
229
|
+
} else if (msgtype === 'richtext') {
|
|
230
|
+
attachments = richTextEntries(repliedContent)
|
|
231
|
+
.filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
|
|
232
|
+
.map(() => ({ kind: 'image' }));
|
|
233
|
+
} else if (msgtype === 'voice' || msgtype === 'audio') {
|
|
234
|
+
attachments = [{ kind: 'audio' }];
|
|
235
|
+
} else if (msgtype === 'video') {
|
|
236
|
+
attachments = [{ kind: 'video' }];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const messageId = nonEmptyString(replied.msgId ?? replied.messageId);
|
|
240
|
+
const authorId = nonEmptyString(replied.senderId ?? replied.senderStaffId);
|
|
241
|
+
const authorName = nonEmptyString(replied.senderNick ?? replied.senderName);
|
|
242
|
+
const content = usefulReplyText(normalized.content)
|
|
243
|
+
?? usefulReplyText(repliedContent.text)
|
|
244
|
+
?? usefulReplyText(repliedContent.summary)
|
|
245
|
+
?? usefulReplyText(repliedContent.title);
|
|
246
|
+
const processQueryKey = nonEmptyString(
|
|
247
|
+
message?.originalProcessQueryKey ?? repliedContent.processQueryKey,
|
|
248
|
+
);
|
|
249
|
+
const createdAt = dingtalkTimestampMs(replied.createdAt ?? replied.createTime);
|
|
250
|
+
const load = !content && attachments.length === 0
|
|
251
|
+
&& typeof options?.loadReplyContent === 'function'
|
|
252
|
+
? ({ signal } = {}) => options.loadReplyContent({
|
|
253
|
+
...(messageId ? { messageId } : {}),
|
|
254
|
+
...(processQueryKey ? { processQueryKey } : {}),
|
|
255
|
+
...(createdAt === null ? {} : { createdAt }),
|
|
256
|
+
}, { signal })
|
|
257
|
+
: null;
|
|
258
|
+
const supported = [
|
|
259
|
+
'text', 'picture', 'file', 'richtext', 'voice', 'audio', 'video',
|
|
260
|
+
'interactivecard', 'chatrecord',
|
|
261
|
+
]
|
|
262
|
+
.includes(msgtype);
|
|
263
|
+
return {
|
|
264
|
+
...(messageId ? { messageId } : {}),
|
|
265
|
+
...(authorId ? { authorId } : {}),
|
|
266
|
+
...(authorName ? { authorName } : {}),
|
|
267
|
+
...(content ? { content } : {}),
|
|
268
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
269
|
+
...(load ? { load } : {}),
|
|
270
|
+
...(!content && attachments.length === 0 && !load
|
|
271
|
+
? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
|
|
272
|
+
: {}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
188
276
|
/** Normalize DingTalk picture and richText callbacks into lazy image references. */
|
|
189
277
|
export function dingtalkInboundMessage(message, {
|
|
190
278
|
api,
|
|
191
279
|
clientId,
|
|
192
280
|
clientSecret,
|
|
281
|
+
loadReplyContent,
|
|
193
282
|
} = {}) {
|
|
194
283
|
const msgtype = String(message?.msgtype ?? '').toLowerCase();
|
|
195
284
|
const content = parsedMessageContent(message);
|
|
@@ -209,6 +298,12 @@ export function dingtalkInboundMessage(message, {
|
|
|
209
298
|
}
|
|
210
299
|
}
|
|
211
300
|
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
|
301
|
+
const replyTo = dingtalkReplyReference(message, {
|
|
302
|
+
api,
|
|
303
|
+
clientId,
|
|
304
|
+
clientSecret,
|
|
305
|
+
loadReplyContent,
|
|
306
|
+
});
|
|
212
307
|
return {
|
|
213
308
|
content: text,
|
|
214
309
|
images: imageCodes.map((downloadCode, index) => ({
|
|
@@ -242,6 +337,7 @@ export function dingtalkInboundMessage(message, {
|
|
|
242
337
|
});
|
|
243
338
|
},
|
|
244
339
|
}] : [],
|
|
340
|
+
...(replyTo ? { replyTo } : {}),
|
|
245
341
|
};
|
|
246
342
|
}
|
|
247
343
|
|
|
@@ -465,11 +561,7 @@ export class DingtalkHarnessBridge {
|
|
|
465
561
|
// An unsafe reply route must never be able to submit an approval.
|
|
466
562
|
}
|
|
467
563
|
const pending = this.#pendingInteractions.get(key);
|
|
468
|
-
const promptMessage =
|
|
469
|
-
api: this.#api,
|
|
470
|
-
clientId: this.#clientId,
|
|
471
|
-
clientSecret: this.#clientSecret,
|
|
472
|
-
});
|
|
564
|
+
const promptMessage = this.#inboundMessage(message, key);
|
|
473
565
|
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
|
474
566
|
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
|
475
567
|
const direct = String(message.conversationType) !== '2';
|
|
@@ -533,7 +625,8 @@ export class DingtalkHarnessBridge {
|
|
|
533
625
|
plainText: Boolean(commandText)
|
|
534
626
|
&& String(message?.msgtype).toLowerCase() === 'text'
|
|
535
627
|
&& !hasInboundFiles(promptMessage)
|
|
536
|
-
&& !hasInboundImages(promptMessage)
|
|
628
|
+
&& !hasInboundImages(promptMessage)
|
|
629
|
+
&& !hasReplyReference(promptMessage),
|
|
537
630
|
});
|
|
538
631
|
if (result.handled) {
|
|
539
632
|
if (result.kind === 'submit') {
|
|
@@ -778,11 +871,7 @@ export class DingtalkHarnessBridge {
|
|
|
778
871
|
}
|
|
779
872
|
const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
|
|
780
873
|
const preparedMessage = hasSafeReplyRoute && addressed
|
|
781
|
-
? prefetchInboundFiles(
|
|
782
|
-
api: this.#api,
|
|
783
|
-
clientId: this.#clientId,
|
|
784
|
-
clientSecret: this.#clientSecret,
|
|
785
|
-
}), { signal: this.#signal })
|
|
874
|
+
? prefetchInboundFiles(this.#inboundMessage(message, key), { signal: this.#signal })
|
|
786
875
|
: undefined;
|
|
787
876
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
788
877
|
const current = previous
|
|
@@ -801,6 +890,50 @@ export class DingtalkHarnessBridge {
|
|
|
801
890
|
return current;
|
|
802
891
|
}
|
|
803
892
|
|
|
893
|
+
#inboundMessage(message, key) {
|
|
894
|
+
return dingtalkInboundMessage(message, {
|
|
895
|
+
api: this.#api,
|
|
896
|
+
clientId: this.#clientId,
|
|
897
|
+
clientSecret: this.#clientSecret,
|
|
898
|
+
loadReplyContent: (reference, options) => this.#loadReplyContent(key, reference, options),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async #loadReplyContent(key, reference, { signal } = {}) {
|
|
903
|
+
const indexed = this.#state.recentOutboundTextFor?.({
|
|
904
|
+
conversationKey: key,
|
|
905
|
+
...reference,
|
|
906
|
+
});
|
|
907
|
+
if (indexed) return { content: indexed };
|
|
908
|
+
const quotedAt = dingtalkTimestampMs(reference?.createdAt);
|
|
909
|
+
if (quotedAt === null) return { unavailableReason: 'not-delivered' };
|
|
910
|
+
const sessionId = this.#state.sessionFor(key);
|
|
911
|
+
const session = typeof sessionId === 'string' && sessionId
|
|
912
|
+
? this.#harness.workspaceSession?.(sessionId)
|
|
913
|
+
: null;
|
|
914
|
+
const text = await recoverAssistantTextByTimestamp({
|
|
915
|
+
session,
|
|
916
|
+
quotedAt,
|
|
917
|
+
signal,
|
|
918
|
+
toleranceMs: DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS,
|
|
919
|
+
});
|
|
920
|
+
if (!text) return { unavailableReason: 'not-delivered' };
|
|
921
|
+
try {
|
|
922
|
+
await this.#state.rememberOutboundMessage?.({
|
|
923
|
+
conversationKey: key,
|
|
924
|
+
text,
|
|
925
|
+
sentAt: quotedAt,
|
|
926
|
+
completedAt: quotedAt,
|
|
927
|
+
providerMessageIds: [reference?.processQueryKey, reference?.messageId]
|
|
928
|
+
.map(nonEmptyString)
|
|
929
|
+
.filter(Boolean),
|
|
930
|
+
});
|
|
931
|
+
} catch (error) {
|
|
932
|
+
this.#logger.warn?.('[dsh-dingtalk] failed to remember a recovered quote:', error);
|
|
933
|
+
}
|
|
934
|
+
return { content: text };
|
|
935
|
+
}
|
|
936
|
+
|
|
804
937
|
async waitForIdle() {
|
|
805
938
|
await Promise.allSettled([
|
|
806
939
|
...this.#queues.values(),
|
|
@@ -936,20 +1069,18 @@ export class DingtalkHarnessBridge {
|
|
|
936
1069
|
return;
|
|
937
1070
|
}
|
|
938
1071
|
|
|
939
|
-
const promptMessage = preparedMessage ??
|
|
940
|
-
api: this.#api,
|
|
941
|
-
clientId: this.#clientId,
|
|
942
|
-
clientSecret: this.#clientSecret,
|
|
943
|
-
});
|
|
1072
|
+
const promptMessage = preparedMessage ?? this.#inboundMessage(message, key);
|
|
944
1073
|
const text = promptMessage.content;
|
|
945
1074
|
const hasImages = hasInboundImages(promptMessage);
|
|
946
1075
|
const hasFiles = hasInboundFiles(promptMessage);
|
|
1076
|
+
const hasReply = hasReplyReference(promptMessage);
|
|
947
1077
|
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
|
948
1078
|
let cardStream = null;
|
|
949
1079
|
let cardStarted = false;
|
|
1080
|
+
let cardStartedAt = null;
|
|
950
1081
|
let batchSettled = batchSubmission === null;
|
|
951
1082
|
try {
|
|
952
|
-
if (!text && !hasImages && !hasFiles) {
|
|
1083
|
+
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
953
1084
|
await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'), this.#atUsersFor(message));
|
|
954
1085
|
return;
|
|
955
1086
|
}
|
|
@@ -992,8 +1123,8 @@ export class DingtalkHarnessBridge {
|
|
|
992
1123
|
return;
|
|
993
1124
|
}
|
|
994
1125
|
|
|
995
|
-
let content = hasImages
|
|
996
|
-
? await
|
|
1126
|
+
let content = hasImages || hasReply
|
|
1127
|
+
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
|
|
997
1128
|
: undefined;
|
|
998
1129
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
999
1130
|
let contextEnhanced = false;
|
|
@@ -1018,7 +1149,9 @@ export class DingtalkHarnessBridge {
|
|
|
1018
1149
|
signal: this.#signal,
|
|
1019
1150
|
logger: this.#logger,
|
|
1020
1151
|
});
|
|
1152
|
+
const startedAt = Date.now();
|
|
1021
1153
|
cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
|
|
1154
|
+
if (cardStarted) cardStartedAt = startedAt;
|
|
1022
1155
|
}
|
|
1023
1156
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
1024
1157
|
harness: this.#harness,
|
|
@@ -1056,12 +1189,14 @@ export class DingtalkHarnessBridge {
|
|
|
1056
1189
|
let textDeliveryError = null;
|
|
1057
1190
|
let textReceipt = null;
|
|
1058
1191
|
let streamed = false;
|
|
1192
|
+
const deliveryStartedAt = cardStartedAt ?? Date.now();
|
|
1059
1193
|
try {
|
|
1060
1194
|
streamed = cardStarted && await cardStream.finish(answerText);
|
|
1061
1195
|
if (streamed) {
|
|
1062
1196
|
textReceipt = createDeliveryReceipt({
|
|
1063
1197
|
deliveryId: messageId,
|
|
1064
1198
|
presentation: 'dingtalk-card',
|
|
1199
|
+
providerMessageIds: cardStream.providerMessageIds,
|
|
1065
1200
|
});
|
|
1066
1201
|
} else {
|
|
1067
1202
|
textReceipt = createDeliveryReceipt({
|
|
@@ -1070,6 +1205,17 @@ export class DingtalkHarnessBridge {
|
|
|
1070
1205
|
providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
|
|
1071
1206
|
});
|
|
1072
1207
|
}
|
|
1208
|
+
try {
|
|
1209
|
+
await this.#state.rememberOutboundMessage?.({
|
|
1210
|
+
conversationKey: key,
|
|
1211
|
+
text: answerText,
|
|
1212
|
+
sentAt: deliveryStartedAt,
|
|
1213
|
+
completedAt: Date.now(),
|
|
1214
|
+
providerMessageIds: providerMessageIdsFor(textReceipt),
|
|
1215
|
+
});
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
this.#logger.warn?.('[dsh-dingtalk] failed to remember an outbound message:', error);
|
|
1218
|
+
}
|
|
1073
1219
|
} catch (error) {
|
|
1074
1220
|
textDeliveryError = channelDeliveryFailure(error);
|
|
1075
1221
|
}
|
|
@@ -32,7 +32,7 @@ function requiredCredential(value, name) {
|
|
|
32
32
|
* @param {number} [options.updateIntervalMs=500] Minimum delay between updates.
|
|
33
33
|
* @param {()=>number} [options.clock] Monotonic millisecond clock.
|
|
34
34
|
* @param {{setTimeout: Function, clearTimeout: Function}} [options.timer] Timer implementation.
|
|
35
|
-
* @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean
|
|
35
|
+
* @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>, readonly providerMessageIds: string[]}}
|
|
36
36
|
* Card stream controller.
|
|
37
37
|
*/
|
|
38
38
|
export function createDingTalkCardStream({
|
|
@@ -231,5 +231,12 @@ export function createDingTalkCardStream({
|
|
|
231
231
|
return finishPromise;
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
-
return Object.freeze({
|
|
234
|
+
return Object.freeze({
|
|
235
|
+
start,
|
|
236
|
+
push,
|
|
237
|
+
finish,
|
|
238
|
+
get providerMessageIds() {
|
|
239
|
+
return cardRequest?.cardInstanceId ? [cardRequest.cardInstanceId] : [];
|
|
240
|
+
},
|
|
241
|
+
});
|
|
235
242
|
}
|
|
@@ -9,8 +9,14 @@ const EMPTY_STATE = Object.freeze({
|
|
|
9
9
|
sessions: {},
|
|
10
10
|
seenMessageIds: [],
|
|
11
11
|
pendingSenders: {},
|
|
12
|
+
recentOutboundMessages: [],
|
|
12
13
|
});
|
|
13
14
|
|
|
15
|
+
export const DINGTALK_RECENT_OUTBOUND_LIMIT = 200;
|
|
16
|
+
export const DINGTALK_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
17
|
+
export const DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
|
|
18
|
+
export const DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
|
|
19
|
+
|
|
14
20
|
function nonEmptyString(value) {
|
|
15
21
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
16
22
|
}
|
|
@@ -19,6 +25,49 @@ function displayName(value) {
|
|
|
19
25
|
return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
function timestampMs(value) {
|
|
29
|
+
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
30
|
+
if (!Number.isFinite(number) || number < 0) return null;
|
|
31
|
+
return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function providerMessageIds(value) {
|
|
35
|
+
if (!Array.isArray(value)) return [];
|
|
36
|
+
return [...new Set(value
|
|
37
|
+
.map((id) => (id === undefined || id === null ? null : nonEmptyString(String(id))))
|
|
38
|
+
.filter(Boolean))];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateText(value) {
|
|
42
|
+
const text = nonEmptyString(value);
|
|
43
|
+
return text ? [...text].slice(0, DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT).join('') : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeRecentOutboundMessage(value) {
|
|
47
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
48
|
+
const conversationKey = nonEmptyString(value.conversationKey);
|
|
49
|
+
const text = truncateText(value.text);
|
|
50
|
+
const sentAt = timestampMs(value.sentAt);
|
|
51
|
+
const completedAt = timestampMs(value.completedAt) ?? sentAt;
|
|
52
|
+
if (!conversationKey || !text || sentAt === null || completedAt === null) return null;
|
|
53
|
+
return {
|
|
54
|
+
conversationKey,
|
|
55
|
+
text,
|
|
56
|
+
sentAt,
|
|
57
|
+
completedAt: Math.max(sentAt, completedAt),
|
|
58
|
+
providerMessageIds: providerMessageIds(value.providerMessageIds),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function recentOutboundMessages(value, now = Date.now()) {
|
|
63
|
+
if (!Array.isArray(value)) return [];
|
|
64
|
+
const cutoff = now - DINGTALK_RECENT_OUTBOUND_TTL_MS;
|
|
65
|
+
return value
|
|
66
|
+
.map(normalizeRecentOutboundMessage)
|
|
67
|
+
.filter((entry) => entry && entry.completedAt >= cutoff)
|
|
68
|
+
.slice(-DINGTALK_RECENT_OUTBOUND_LIMIT);
|
|
69
|
+
}
|
|
70
|
+
|
|
22
71
|
function normalizePendingSender(value, fallbackRequestId) {
|
|
23
72
|
if (!value || typeof value !== 'object') return null;
|
|
24
73
|
const requestId = nonEmptyString(value.requestId) ?? nonEmptyString(fallbackRequestId);
|
|
@@ -69,6 +118,7 @@ function normalizeState(value) {
|
|
|
69
118
|
? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000)
|
|
70
119
|
: [],
|
|
71
120
|
pendingSenders,
|
|
121
|
+
recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
|
|
72
122
|
};
|
|
73
123
|
}
|
|
74
124
|
|
|
@@ -140,6 +190,54 @@ export class DingtalkStateStore {
|
|
|
140
190
|
await this.#persist();
|
|
141
191
|
}
|
|
142
192
|
|
|
193
|
+
async rememberOutboundMessage({
|
|
194
|
+
conversationKey,
|
|
195
|
+
text,
|
|
196
|
+
sentAt = Date.now(),
|
|
197
|
+
completedAt = Date.now(),
|
|
198
|
+
providerMessageIds: messageIds = [],
|
|
199
|
+
} = {}) {
|
|
200
|
+
const entry = normalizeRecentOutboundMessage({
|
|
201
|
+
conversationKey,
|
|
202
|
+
text,
|
|
203
|
+
sentAt,
|
|
204
|
+
completedAt,
|
|
205
|
+
providerMessageIds: messageIds,
|
|
206
|
+
});
|
|
207
|
+
if (!entry) throw new TypeError('Invalid DingTalk outbound message');
|
|
208
|
+
this.#state.recentOutboundMessages = recentOutboundMessages([
|
|
209
|
+
...this.#state.recentOutboundMessages,
|
|
210
|
+
entry,
|
|
211
|
+
]);
|
|
212
|
+
await this.#persist();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
recentOutboundTextFor({
|
|
216
|
+
conversationKey,
|
|
217
|
+
processQueryKey,
|
|
218
|
+
messageId,
|
|
219
|
+
createdAt,
|
|
220
|
+
now = Date.now(),
|
|
221
|
+
} = {}) {
|
|
222
|
+
const key = nonEmptyString(conversationKey);
|
|
223
|
+
if (!key) return null;
|
|
224
|
+
const active = recentOutboundMessages(this.#state.recentOutboundMessages, now)
|
|
225
|
+
.filter((entry) => entry.conversationKey === key);
|
|
226
|
+
const quotedIds = providerMessageIds([processQueryKey, messageId]);
|
|
227
|
+
for (const quotedId of quotedIds) {
|
|
228
|
+
const exact = active.filter((entry) => entry.providerMessageIds.includes(quotedId));
|
|
229
|
+
if (exact.length === 1) return exact[0].text;
|
|
230
|
+
if (exact.length > 1) return null;
|
|
231
|
+
}
|
|
232
|
+
const quotedAt = timestampMs(createdAt);
|
|
233
|
+
if (quotedAt === null) return null;
|
|
234
|
+
const candidates = active.filter((entry) => (
|
|
235
|
+
quotedAt >= entry.sentAt - DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
|
236
|
+
&& quotedAt <= entry.completedAt + DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
|
237
|
+
));
|
|
238
|
+
return candidates.length === 1 ? candidates[0].text : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
143
241
|
pendingSenders() {
|
|
144
242
|
return Object.values(this.#state.pendingSenders)
|
|
145
243
|
.sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))
|
|
@@ -134,6 +134,13 @@ export class DiscordApi {
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
getMessage({ channelId, messageId, signal } = {}) {
|
|
138
|
+
return this.#request(
|
|
139
|
+
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
|
|
140
|
+
{ method: 'GET', signal },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
137
144
|
startThreadFromMessage({ channelId, messageId, name, signal } = {}) {
|
|
138
145
|
const threadName = cleanString(name);
|
|
139
146
|
if (!threadName || [...threadName].length > 100) {
|
|
@@ -206,12 +206,101 @@ function discordFileSource(attachment, fetchImpl) {
|
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
function discordReplyAttachment(attachment) {
|
|
210
|
+
if (!attachment || typeof attachment !== 'object') return null;
|
|
211
|
+
const mediaType = typeof attachment.content_type === 'string'
|
|
212
|
+
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
|
|
213
|
+
const kind = mediaType.startsWith('image/') ? 'image'
|
|
214
|
+
: mediaType.startsWith('audio/') ? 'audio'
|
|
215
|
+
: mediaType.startsWith('video/') ? 'video' : 'file';
|
|
216
|
+
const name = typeof attachment.filename === 'string' && attachment.filename
|
|
217
|
+
? attachment.filename : undefined;
|
|
218
|
+
return { kind, ...(name ? { name } : {}) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function discordReplySnapshot(message, fallbackMessageId) {
|
|
222
|
+
if (!message || typeof message !== 'object') return null;
|
|
223
|
+
const messageId = typeof message.id === 'string' && message.id
|
|
224
|
+
? message.id : fallbackMessageId;
|
|
225
|
+
const authorId = typeof message.author?.id === 'string' && message.author.id
|
|
226
|
+
? message.author.id : undefined;
|
|
227
|
+
const authorName = [message.member?.nick, message.author?.global_name, message.author?.username]
|
|
228
|
+
.find((value) => typeof value === 'string' && value.trim());
|
|
229
|
+
const attachments = Array.isArray(message.attachments)
|
|
230
|
+
? message.attachments.map(discordReplyAttachment).filter(Boolean)
|
|
231
|
+
: [];
|
|
232
|
+
if (Array.isArray(message.sticker_items)) {
|
|
233
|
+
attachments.push(...message.sticker_items.map((sticker) => ({
|
|
234
|
+
kind: 'image',
|
|
235
|
+
...(typeof sticker?.name === 'string' && sticker.name ? { name: sticker.name } : {}),
|
|
236
|
+
})));
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
...(messageId ? { messageId: String(messageId) } : {}),
|
|
240
|
+
...(authorId ? { authorId } : {}),
|
|
241
|
+
...(authorName ? { authorName } : {}),
|
|
242
|
+
content: typeof message.content === 'string' ? message.content : '',
|
|
243
|
+
attachments,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function discordReplyReference(message, loadReply) {
|
|
248
|
+
const channelId = String(message?.channel_id ?? '');
|
|
249
|
+
const referenceId = typeof message?.message_reference?.message_id === 'string'
|
|
250
|
+
&& message.message_reference.message_id
|
|
251
|
+
? message.message_reference.message_id : undefined;
|
|
252
|
+
const referenceChannelId = message?.message_reference?.channel_id;
|
|
253
|
+
if (referenceChannelId !== undefined && String(referenceChannelId) !== channelId) {
|
|
254
|
+
return {
|
|
255
|
+
...(referenceId ? { messageId: referenceId } : {}),
|
|
256
|
+
unavailableReason: 'not-found',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (Object.hasOwn(message ?? {}, 'referenced_message')) {
|
|
260
|
+
if (message.referenced_message === null) {
|
|
261
|
+
return {
|
|
262
|
+
...(referenceId ? { messageId: referenceId } : {}),
|
|
263
|
+
unavailableReason: 'deleted',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (message.referenced_message && typeof message.referenced_message === 'object') {
|
|
267
|
+
const snapshotId = typeof message.referenced_message.id === 'string'
|
|
268
|
+
&& message.referenced_message.id ? message.referenced_message.id : undefined;
|
|
269
|
+
if (String(message.referenced_message.channel_id ?? '') !== channelId
|
|
270
|
+
|| !snapshotId || (referenceId && snapshotId !== referenceId)) {
|
|
271
|
+
return {
|
|
272
|
+
...(referenceId ? { messageId: referenceId } : {}),
|
|
273
|
+
unavailableReason: 'not-found',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return discordReplySnapshot(message.referenced_message, referenceId) ?? undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!referenceId) return undefined;
|
|
280
|
+
if (typeof loadReply !== 'function') {
|
|
281
|
+
return { messageId: referenceId, unavailableReason: 'not-delivered' };
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
messageId: referenceId,
|
|
285
|
+
load: async ({ signal } = {}) => {
|
|
286
|
+
const referenced = await loadReply({ channelId, messageId: referenceId, signal });
|
|
287
|
+
if (!referenced || String(referenced.id ?? '') !== referenceId
|
|
288
|
+
|| String(referenced.channel_id ?? '') !== channelId) return null;
|
|
289
|
+
return discordReplySnapshot(referenced, referenceId);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function normalizeDiscordMessage(message, botId, {
|
|
295
|
+
fetchImpl = fetch,
|
|
296
|
+
loadReply,
|
|
297
|
+
} = {}) {
|
|
210
298
|
if (!message?.id || !message?.channel_id || !message?.author?.id
|
|
211
299
|
|| Number(message.type) === 21) return null;
|
|
212
300
|
const direct = !message.guild_id;
|
|
213
301
|
const addressed = direct
|
|
214
302
|
|| message.mentions?.some((mention) => String(mention?.id) === String(botId));
|
|
303
|
+
const replyTo = discordReplyReference(message, loadReply);
|
|
215
304
|
return {
|
|
216
305
|
messageId: String(message.id),
|
|
217
306
|
senderId: String(message.author.id),
|
|
@@ -232,6 +321,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|
|
232
321
|
files: Array.isArray(message.attachments)
|
|
233
322
|
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
|
|
234
323
|
: [],
|
|
324
|
+
...(replyTo ? { replyTo } : {}),
|
|
235
325
|
addressed,
|
|
236
326
|
replyTarget: {
|
|
237
327
|
channelId: String(message.channel_id),
|
|
@@ -252,7 +342,12 @@ export async function resolveDiscordMessageRoute(message, botId, {
|
|
|
252
342
|
signal,
|
|
253
343
|
onChannel,
|
|
254
344
|
} = {}) {
|
|
255
|
-
const normalized = normalizeDiscordMessage(message, botId, {
|
|
345
|
+
const normalized = normalizeDiscordMessage(message, botId, {
|
|
346
|
+
fetchImpl,
|
|
347
|
+
loadReply: typeof api?.getMessage === 'function'
|
|
348
|
+
? (options) => api.getMessage(options)
|
|
349
|
+
: undefined,
|
|
350
|
+
});
|
|
256
351
|
if (!normalized || normalized.senderIsBot) return normalized;
|
|
257
352
|
signal?.throwIfAborted();
|
|
258
353
|
if (normalized.kind === 'direct') {
|
|
@@ -11,8 +11,11 @@ import {
|
|
|
11
11
|
hasInboundImages,
|
|
12
12
|
imagePromptDiagnostic,
|
|
13
13
|
imagePromptUserMessage,
|
|
14
|
-
promptContentForMessage,
|
|
15
14
|
} from '../shared/image-prompt.mjs';
|
|
15
|
+
import {
|
|
16
|
+
hasReplyReference,
|
|
17
|
+
promptContentForInboundMessage,
|
|
18
|
+
} from '../shared/semantic/reply-reference.mjs';
|
|
16
19
|
import {
|
|
17
20
|
hasInboundFiles,
|
|
18
21
|
inboundFileUserMessage,
|
|
@@ -626,7 +629,9 @@ export class FeishuHarnessBridge {
|
|
|
626
629
|
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
|
627
630
|
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
|
628
631
|
: this.#batchInputs.handle(key, batchText, {
|
|
629
|
-
plainText: event.message.message_type === 'text'
|
|
632
|
+
plainText: event.message.message_type === 'text'
|
|
633
|
+
&& Boolean(batchText)
|
|
634
|
+
&& !hasReplyReference(commandMessage),
|
|
630
635
|
});
|
|
631
636
|
if (result.handled) {
|
|
632
637
|
if (result.kind === 'submit') {
|
|
@@ -1032,11 +1037,12 @@ export class FeishuHarnessBridge {
|
|
|
1032
1037
|
const text = message.content;
|
|
1033
1038
|
const hasImages = hasInboundImages(message);
|
|
1034
1039
|
const hasFiles = hasInboundFiles(message);
|
|
1040
|
+
const hasReply = hasReplyReference(message);
|
|
1035
1041
|
// 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
|
|
1036
1042
|
// 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
|
|
1037
1043
|
// accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
|
|
1038
1044
|
const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
|
|
1039
|
-
if (!text && !hasImages && !hasFiles) {
|
|
1045
|
+
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
1040
1046
|
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
|
|
1041
1047
|
return;
|
|
1042
1048
|
}
|
|
@@ -3289,8 +3295,8 @@ export class FeishuHarnessBridge {
|
|
|
3289
3295
|
askCompleted = true;
|
|
3290
3296
|
onAskComplete?.();
|
|
3291
3297
|
};
|
|
3292
|
-
let content = hasInboundImages(message)
|
|
3293
|
-
? await
|
|
3298
|
+
let content = hasInboundImages(message) || hasReplyReference(message)
|
|
3299
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
3294
3300
|
: undefined;
|
|
3295
3301
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
3296
3302
|
let contextEnhanced = false;
|