@xmanrui/dsh-im 4.5.0 → 4.7.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/README.en.md +2 -0
- package/README.md +2 -0
- package/lib/client.js +4 -1
- package/lib/index.js +249 -240
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +167 -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 +44 -13
- package/src/channels/feishu/feishu-cards.mjs +2 -0
- package/src/channels/feishu/message-utils.mjs +229 -0
- package/src/channels/qq/qq-bridge.mjs +49 -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/feishu.mjs +2 -2
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +2 -2
- 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 +13 -6
- package/src/channels/shared/workspace-command.mjs +22 -3
- 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 +50 -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 +97 -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,
|
|
@@ -77,6 +82,7 @@ const HELP_TEXT_LINES = [
|
|
|
77
82
|
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
78
83
|
'/workspacelist 列出工作区绝对路径',
|
|
79
84
|
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
85
|
+
'/sessionlist --limit N 仅列出当前工作区前 N 个会话',
|
|
80
86
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
81
87
|
'/models 按序号列出所有可用模型',
|
|
82
88
|
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
|
|
@@ -185,11 +191,95 @@ function downloadCodeFor(value) {
|
|
|
185
191
|
return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
function dingtalkTimestampMs(value) {
|
|
195
|
+
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
196
|
+
if (!Number.isFinite(number) || number < 0) return null;
|
|
197
|
+
return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function usefulReplyText(value) {
|
|
201
|
+
const text = nonEmptyString(value);
|
|
202
|
+
return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function dingtalkReplyReference(message, options) {
|
|
206
|
+
const replyEnvelope = message?.text;
|
|
207
|
+
if (replyEnvelope?.isReplyMsg !== true) return null;
|
|
208
|
+
const replied = replyEnvelope?.repliedMsg;
|
|
209
|
+
if (!replied || typeof replied !== 'object') {
|
|
210
|
+
return { unavailableReason: 'not-delivered' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
|
|
214
|
+
const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
|
|
215
|
+
const pseudoMessage = {
|
|
216
|
+
msgtype,
|
|
217
|
+
text: {
|
|
218
|
+
content: nonEmptyString(repliedContent.text)
|
|
219
|
+
?? (typeof replied.content === 'string' ? replied.content : ''),
|
|
220
|
+
},
|
|
221
|
+
content: repliedContent,
|
|
222
|
+
};
|
|
223
|
+
const normalized = dingtalkInboundMessage(pseudoMessage, options);
|
|
224
|
+
let attachments = [];
|
|
225
|
+
if (msgtype === 'picture') {
|
|
226
|
+
attachments = [{ kind: 'image' }];
|
|
227
|
+
} else if (msgtype === 'file') {
|
|
228
|
+
const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
|
|
229
|
+
attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
|
|
230
|
+
} else if (msgtype === 'richtext') {
|
|
231
|
+
attachments = richTextEntries(repliedContent)
|
|
232
|
+
.filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
|
|
233
|
+
.map(() => ({ kind: 'image' }));
|
|
234
|
+
} else if (msgtype === 'voice' || msgtype === 'audio') {
|
|
235
|
+
attachments = [{ kind: 'audio' }];
|
|
236
|
+
} else if (msgtype === 'video') {
|
|
237
|
+
attachments = [{ kind: 'video' }];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const messageId = nonEmptyString(replied.msgId ?? replied.messageId);
|
|
241
|
+
const authorId = nonEmptyString(replied.senderId ?? replied.senderStaffId);
|
|
242
|
+
const authorName = nonEmptyString(replied.senderNick ?? replied.senderName);
|
|
243
|
+
const content = usefulReplyText(normalized.content)
|
|
244
|
+
?? usefulReplyText(repliedContent.text)
|
|
245
|
+
?? usefulReplyText(repliedContent.summary)
|
|
246
|
+
?? usefulReplyText(repliedContent.title);
|
|
247
|
+
const processQueryKey = nonEmptyString(
|
|
248
|
+
message?.originalProcessQueryKey ?? repliedContent.processQueryKey,
|
|
249
|
+
);
|
|
250
|
+
const createdAt = dingtalkTimestampMs(replied.createdAt ?? replied.createTime);
|
|
251
|
+
const load = !content && attachments.length === 0
|
|
252
|
+
&& typeof options?.loadReplyContent === 'function'
|
|
253
|
+
? ({ signal } = {}) => options.loadReplyContent({
|
|
254
|
+
...(messageId ? { messageId } : {}),
|
|
255
|
+
...(processQueryKey ? { processQueryKey } : {}),
|
|
256
|
+
...(createdAt === null ? {} : { createdAt }),
|
|
257
|
+
}, { signal })
|
|
258
|
+
: null;
|
|
259
|
+
const supported = [
|
|
260
|
+
'text', 'picture', 'file', 'richtext', 'voice', 'audio', 'video',
|
|
261
|
+
'interactivecard', 'chatrecord',
|
|
262
|
+
]
|
|
263
|
+
.includes(msgtype);
|
|
264
|
+
return {
|
|
265
|
+
...(messageId ? { messageId } : {}),
|
|
266
|
+
...(authorId ? { authorId } : {}),
|
|
267
|
+
...(authorName ? { authorName } : {}),
|
|
268
|
+
...(content ? { content } : {}),
|
|
269
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
270
|
+
...(load ? { load } : {}),
|
|
271
|
+
...(!content && attachments.length === 0 && !load
|
|
272
|
+
? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
|
|
273
|
+
: {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
188
277
|
/** Normalize DingTalk picture and richText callbacks into lazy image references. */
|
|
189
278
|
export function dingtalkInboundMessage(message, {
|
|
190
279
|
api,
|
|
191
280
|
clientId,
|
|
192
281
|
clientSecret,
|
|
282
|
+
loadReplyContent,
|
|
193
283
|
} = {}) {
|
|
194
284
|
const msgtype = String(message?.msgtype ?? '').toLowerCase();
|
|
195
285
|
const content = parsedMessageContent(message);
|
|
@@ -209,6 +299,12 @@ export function dingtalkInboundMessage(message, {
|
|
|
209
299
|
}
|
|
210
300
|
}
|
|
211
301
|
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
|
302
|
+
const replyTo = dingtalkReplyReference(message, {
|
|
303
|
+
api,
|
|
304
|
+
clientId,
|
|
305
|
+
clientSecret,
|
|
306
|
+
loadReplyContent,
|
|
307
|
+
});
|
|
212
308
|
return {
|
|
213
309
|
content: text,
|
|
214
310
|
images: imageCodes.map((downloadCode, index) => ({
|
|
@@ -242,6 +338,7 @@ export function dingtalkInboundMessage(message, {
|
|
|
242
338
|
});
|
|
243
339
|
},
|
|
244
340
|
}] : [],
|
|
341
|
+
...(replyTo ? { replyTo } : {}),
|
|
245
342
|
};
|
|
246
343
|
}
|
|
247
344
|
|
|
@@ -465,11 +562,7 @@ export class DingtalkHarnessBridge {
|
|
|
465
562
|
// An unsafe reply route must never be able to submit an approval.
|
|
466
563
|
}
|
|
467
564
|
const pending = this.#pendingInteractions.get(key);
|
|
468
|
-
const promptMessage =
|
|
469
|
-
api: this.#api,
|
|
470
|
-
clientId: this.#clientId,
|
|
471
|
-
clientSecret: this.#clientSecret,
|
|
472
|
-
});
|
|
565
|
+
const promptMessage = this.#inboundMessage(message, key);
|
|
473
566
|
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
|
474
567
|
const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
|
|
475
568
|
const direct = String(message.conversationType) !== '2';
|
|
@@ -533,7 +626,8 @@ export class DingtalkHarnessBridge {
|
|
|
533
626
|
plainText: Boolean(commandText)
|
|
534
627
|
&& String(message?.msgtype).toLowerCase() === 'text'
|
|
535
628
|
&& !hasInboundFiles(promptMessage)
|
|
536
|
-
&& !hasInboundImages(promptMessage)
|
|
629
|
+
&& !hasInboundImages(promptMessage)
|
|
630
|
+
&& !hasReplyReference(promptMessage),
|
|
537
631
|
});
|
|
538
632
|
if (result.handled) {
|
|
539
633
|
if (result.kind === 'submit') {
|
|
@@ -778,11 +872,7 @@ export class DingtalkHarnessBridge {
|
|
|
778
872
|
}
|
|
779
873
|
const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
|
|
780
874
|
const preparedMessage = hasSafeReplyRoute && addressed
|
|
781
|
-
? prefetchInboundFiles(
|
|
782
|
-
api: this.#api,
|
|
783
|
-
clientId: this.#clientId,
|
|
784
|
-
clientSecret: this.#clientSecret,
|
|
785
|
-
}), { signal: this.#signal })
|
|
875
|
+
? prefetchInboundFiles(this.#inboundMessage(message, key), { signal: this.#signal })
|
|
786
876
|
: undefined;
|
|
787
877
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
788
878
|
const current = previous
|
|
@@ -801,6 +891,50 @@ export class DingtalkHarnessBridge {
|
|
|
801
891
|
return current;
|
|
802
892
|
}
|
|
803
893
|
|
|
894
|
+
#inboundMessage(message, key) {
|
|
895
|
+
return dingtalkInboundMessage(message, {
|
|
896
|
+
api: this.#api,
|
|
897
|
+
clientId: this.#clientId,
|
|
898
|
+
clientSecret: this.#clientSecret,
|
|
899
|
+
loadReplyContent: (reference, options) => this.#loadReplyContent(key, reference, options),
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async #loadReplyContent(key, reference, { signal } = {}) {
|
|
904
|
+
const indexed = this.#state.recentOutboundTextFor?.({
|
|
905
|
+
conversationKey: key,
|
|
906
|
+
...reference,
|
|
907
|
+
});
|
|
908
|
+
if (indexed) return { content: indexed };
|
|
909
|
+
const quotedAt = dingtalkTimestampMs(reference?.createdAt);
|
|
910
|
+
if (quotedAt === null) return { unavailableReason: 'not-delivered' };
|
|
911
|
+
const sessionId = this.#state.sessionFor(key);
|
|
912
|
+
const session = typeof sessionId === 'string' && sessionId
|
|
913
|
+
? this.#harness.workspaceSession?.(sessionId)
|
|
914
|
+
: null;
|
|
915
|
+
const text = await recoverAssistantTextByTimestamp({
|
|
916
|
+
session,
|
|
917
|
+
quotedAt,
|
|
918
|
+
signal,
|
|
919
|
+
toleranceMs: DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS,
|
|
920
|
+
});
|
|
921
|
+
if (!text) return { unavailableReason: 'not-delivered' };
|
|
922
|
+
try {
|
|
923
|
+
await this.#state.rememberOutboundMessage?.({
|
|
924
|
+
conversationKey: key,
|
|
925
|
+
text,
|
|
926
|
+
sentAt: quotedAt,
|
|
927
|
+
completedAt: quotedAt,
|
|
928
|
+
providerMessageIds: [reference?.processQueryKey, reference?.messageId]
|
|
929
|
+
.map(nonEmptyString)
|
|
930
|
+
.filter(Boolean),
|
|
931
|
+
});
|
|
932
|
+
} catch (error) {
|
|
933
|
+
this.#logger.warn?.('[dsh-dingtalk] failed to remember a recovered quote:', error);
|
|
934
|
+
}
|
|
935
|
+
return { content: text };
|
|
936
|
+
}
|
|
937
|
+
|
|
804
938
|
async waitForIdle() {
|
|
805
939
|
await Promise.allSettled([
|
|
806
940
|
...this.#queues.values(),
|
|
@@ -936,20 +1070,18 @@ export class DingtalkHarnessBridge {
|
|
|
936
1070
|
return;
|
|
937
1071
|
}
|
|
938
1072
|
|
|
939
|
-
const promptMessage = preparedMessage ??
|
|
940
|
-
api: this.#api,
|
|
941
|
-
clientId: this.#clientId,
|
|
942
|
-
clientSecret: this.#clientSecret,
|
|
943
|
-
});
|
|
1073
|
+
const promptMessage = preparedMessage ?? this.#inboundMessage(message, key);
|
|
944
1074
|
const text = promptMessage.content;
|
|
945
1075
|
const hasImages = hasInboundImages(promptMessage);
|
|
946
1076
|
const hasFiles = hasInboundFiles(promptMessage);
|
|
1077
|
+
const hasReply = hasReplyReference(promptMessage);
|
|
947
1078
|
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
|
948
1079
|
let cardStream = null;
|
|
949
1080
|
let cardStarted = false;
|
|
1081
|
+
let cardStartedAt = null;
|
|
950
1082
|
let batchSettled = batchSubmission === null;
|
|
951
1083
|
try {
|
|
952
|
-
if (!text && !hasImages && !hasFiles) {
|
|
1084
|
+
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
953
1085
|
await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'), this.#atUsersFor(message));
|
|
954
1086
|
return;
|
|
955
1087
|
}
|
|
@@ -992,8 +1124,8 @@ export class DingtalkHarnessBridge {
|
|
|
992
1124
|
return;
|
|
993
1125
|
}
|
|
994
1126
|
|
|
995
|
-
let content = hasImages
|
|
996
|
-
? await
|
|
1127
|
+
let content = hasImages || hasReply
|
|
1128
|
+
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
|
|
997
1129
|
: undefined;
|
|
998
1130
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
999
1131
|
let contextEnhanced = false;
|
|
@@ -1018,7 +1150,9 @@ export class DingtalkHarnessBridge {
|
|
|
1018
1150
|
signal: this.#signal,
|
|
1019
1151
|
logger: this.#logger,
|
|
1020
1152
|
});
|
|
1153
|
+
const startedAt = Date.now();
|
|
1021
1154
|
cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
|
|
1155
|
+
if (cardStarted) cardStartedAt = startedAt;
|
|
1022
1156
|
}
|
|
1023
1157
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
1024
1158
|
harness: this.#harness,
|
|
@@ -1056,12 +1190,14 @@ export class DingtalkHarnessBridge {
|
|
|
1056
1190
|
let textDeliveryError = null;
|
|
1057
1191
|
let textReceipt = null;
|
|
1058
1192
|
let streamed = false;
|
|
1193
|
+
const deliveryStartedAt = cardStartedAt ?? Date.now();
|
|
1059
1194
|
try {
|
|
1060
1195
|
streamed = cardStarted && await cardStream.finish(answerText);
|
|
1061
1196
|
if (streamed) {
|
|
1062
1197
|
textReceipt = createDeliveryReceipt({
|
|
1063
1198
|
deliveryId: messageId,
|
|
1064
1199
|
presentation: 'dingtalk-card',
|
|
1200
|
+
providerMessageIds: cardStream.providerMessageIds,
|
|
1065
1201
|
});
|
|
1066
1202
|
} else {
|
|
1067
1203
|
textReceipt = createDeliveryReceipt({
|
|
@@ -1070,6 +1206,17 @@ export class DingtalkHarnessBridge {
|
|
|
1070
1206
|
providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
|
|
1071
1207
|
});
|
|
1072
1208
|
}
|
|
1209
|
+
try {
|
|
1210
|
+
await this.#state.rememberOutboundMessage?.({
|
|
1211
|
+
conversationKey: key,
|
|
1212
|
+
text: answerText,
|
|
1213
|
+
sentAt: deliveryStartedAt,
|
|
1214
|
+
completedAt: Date.now(),
|
|
1215
|
+
providerMessageIds: providerMessageIdsFor(textReceipt),
|
|
1216
|
+
});
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
this.#logger.warn?.('[dsh-dingtalk] failed to remember an outbound message:', error);
|
|
1219
|
+
}
|
|
1073
1220
|
} catch (error) {
|
|
1074
1221
|
textDeliveryError = channelDeliveryFailure(error);
|
|
1075
1222
|
}
|
|
@@ -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') {
|