@xmanrui/dsh-im 4.4.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/README.en.md +2 -2
- package/README.md +2 -2
- package/lib/client.js +29 -9
- package/lib/index.js +252 -241
- package/package.json +1 -1
- package/plugin-src/client/context-enhancement.js +19 -5
- package/plugin-src/client/i18n.js +5 -1
- package/plugin-src/host/modern-harness-api.mjs +3 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +175 -23
- 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 +30 -7
- package/src/channels/feishu/feishu-cards.mjs +2 -2
- package/src/channels/feishu/feishu-channel.mjs +36 -6
- package/src/channels/feishu/message-utils.mjs +229 -0
- package/src/channels/qq/qq-bridge.mjs +56 -9
- package/src/channels/shared/batch-input.mjs +3 -3
- package/src/channels/shared/bot-workspace-store.mjs +5 -0
- package/src/channels/shared/context-enhancement.mjs +9 -4
- package/src/channels/shared/harness-client.mjs +88 -30
- package/src/channels/shared/i18n-en/feishu.mjs +3 -3
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
- package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
- 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 +74 -0
- package/src/channels/shared/text-harness-bridge.mjs +19 -8
- package/src/channels/shared/workspace-command.mjs +16 -4
- package/src/channels/shared/workspace-session.mjs +28 -2
- 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 +118 -2
- package/src/channels/wecom/wecom-bridge.mjs +55 -9
- 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 +104 -12
- package/src/channels/weixin/weixin-runtime.mjs +26 -6
- package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
package/package.json
CHANGED
|
@@ -16,15 +16,28 @@ const FIELD_LABELS = Object.freeze({
|
|
|
16
16
|
conversationType: '会话类型',
|
|
17
17
|
senderId: '发送者标识',
|
|
18
18
|
senderName: '发送者昵称',
|
|
19
|
+
conversationTitle: '会话标题',
|
|
19
20
|
botId: '机器人标识',
|
|
20
21
|
});
|
|
21
22
|
|
|
23
|
+
const FIELD_HELP = Object.freeze({
|
|
24
|
+
senderName: Object.freeze({
|
|
25
|
+
labelKey: 'senderNameHelpLabel',
|
|
26
|
+
text: '该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。',
|
|
27
|
+
}),
|
|
28
|
+
conversationTitle: Object.freeze({
|
|
29
|
+
labelKey: 'conversationTitleHelpLabel',
|
|
30
|
+
text: '该字段不是每个渠道都能提供。钉钉群聊会带上群名。当前消息没有会话标题时,即使已选择该字段,<dsh_im_source> 中也会省略 conversationTitle。',
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
22
34
|
const SCOPE_COPY = Object.freeze({
|
|
23
35
|
group: Object.freeze({
|
|
24
36
|
title: '群聊',
|
|
25
37
|
enable: '启用',
|
|
26
38
|
fieldsHelpLabel: '查看群聊来源字段说明',
|
|
27
39
|
senderNameHelpLabel: '查看群聊发送者昵称字段说明',
|
|
40
|
+
conversationTitleHelpLabel: '查看群聊会话标题字段说明',
|
|
28
41
|
guidanceLabel: '增强提示词',
|
|
29
42
|
guidanceHelpLabel: '查看群聊增强提示词使用说明',
|
|
30
43
|
guidanceUsage: '用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
|
@@ -35,6 +48,7 @@ const SCOPE_COPY = Object.freeze({
|
|
|
35
48
|
enable: '启用',
|
|
36
49
|
fieldsHelpLabel: '查看私聊来源字段说明',
|
|
37
50
|
senderNameHelpLabel: '查看私聊发送者昵称字段说明',
|
|
51
|
+
conversationTitleHelpLabel: '查看私聊会话标题字段说明',
|
|
38
52
|
guidanceLabel: '增强提示词',
|
|
39
53
|
guidanceHelpLabel: '查看私聊增强提示词使用说明',
|
|
40
54
|
guidanceUsage: '用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
|
@@ -73,7 +87,6 @@ function ContextEnhancementScopeEditor({
|
|
|
73
87
|
const copy = SCOPE_COPY[kind];
|
|
74
88
|
const unavailableId = `${idPrefix}-${kind}-unavailable`;
|
|
75
89
|
const fieldsHelpId = `${idPrefix}-${kind}-fields-help`;
|
|
76
|
-
const senderNameHelpId = `${idPrefix}-${kind}-sender-name-help`;
|
|
77
90
|
const guidanceId = `${idPrefix}-${kind}-guidance`;
|
|
78
91
|
const guidanceHelpId = `${idPrefix}-${kind}-guidance-help`;
|
|
79
92
|
const disabled = busy || !supported;
|
|
@@ -118,16 +131,17 @@ function ContextEnhancementScopeEditor({
|
|
|
118
131
|
}),
|
|
119
132
|
h('span', { className: 'dim-contextFieldText' },
|
|
120
133
|
h('label', { className: 'dim-contextFieldName', htmlFor: fieldId }, FIELD_LABELS[field]),
|
|
121
|
-
field
|
|
134
|
+
FIELD_HELP[field] ? h('span', { className: 'dim-contextHelp dim-contextFieldHelp' },
|
|
122
135
|
h('button', {
|
|
123
136
|
type: 'button', className: 'dim-contextHelpButton dim-contextFieldHelpButton', disabled,
|
|
124
|
-
'aria-label': copy.
|
|
137
|
+
'aria-label': copy[FIELD_HELP[field].labelKey],
|
|
138
|
+
'aria-describedby': `${idPrefix}-${kind}-${field}-help`,
|
|
125
139
|
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
|
126
140
|
h('span', {
|
|
127
|
-
id:
|
|
141
|
+
id: `${idPrefix}-${kind}-${field}-help`,
|
|
128
142
|
className: 'dim-contextTooltip dim-contextFieldTooltip',
|
|
129
143
|
role: 'tooltip',
|
|
130
|
-
},
|
|
144
|
+
}, FIELD_HELP[field].text)) : null,
|
|
131
145
|
h('label', { className: 'dim-contextFieldKey', htmlFor: fieldId }, field)));
|
|
132
146
|
}))),
|
|
133
147
|
h('div', { className: 'dim-contextGuidance dim-contextScopeBlock' },
|
|
@@ -288,6 +288,10 @@ const EN = Object.freeze({
|
|
|
288
288
|
'查看群聊发送者昵称字段说明': 'View group sender name field details',
|
|
289
289
|
'查看私聊发送者昵称字段说明': 'View direct sender name field details',
|
|
290
290
|
'该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。': 'This field is not available on every channel. If the current message has no sender name, <dsh_im_source> omits senderName even when the field is selected.',
|
|
291
|
+
'会话标题': 'Conversation title',
|
|
292
|
+
'查看群聊会话标题字段说明': 'View group conversation title field details',
|
|
293
|
+
'查看私聊会话标题字段说明': 'View direct conversation title field details',
|
|
294
|
+
'该字段不是每个渠道都能提供。钉钉群聊会带上群名。当前消息没有会话标题时,即使已选择该字段,<dsh_im_source> 中也会省略 conversationTitle。': 'This field is not available on every channel. DingTalk group chats include the group name. If the current message has no conversation title, <dsh_im_source> omits conversationTitle even when the field is selected.',
|
|
291
295
|
'机器人标识': 'Bot ID',
|
|
292
296
|
'增强提示词': 'Guidance',
|
|
293
297
|
'查看增强提示词使用说明': 'View guidance instructions',
|
|
@@ -315,7 +319,7 @@ const EN = Object.freeze({
|
|
|
315
319
|
'上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
|
|
316
320
|
'请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
|
|
317
321
|
'群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
|
|
318
|
-
'
|
|
322
|
+
'来源字段只能选择已定义的六个字段。': 'Source fields must be chosen from the six defined fields.',
|
|
319
323
|
'保存中…': 'Saving…',
|
|
320
324
|
'未设置': 'Not set',
|
|
321
325
|
'工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
|
|
@@ -221,6 +221,9 @@ class ModernHarnessApi {
|
|
|
221
221
|
{ request: { requestId: request.rpcId, ...request.payload } },
|
|
222
222
|
signal,
|
|
223
223
|
)),
|
|
224
|
+
rename: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
225
|
+
'session', 'rename', { request: request.payload }, signal,
|
|
226
|
+
)),
|
|
224
227
|
cancel: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
225
228
|
'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
|
|
226
229
|
)),
|
|
@@ -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,
|
|
@@ -74,7 +79,7 @@ const HELP_TEXT_LINES = [
|
|
|
74
79
|
'/new 开启一个全新会话',
|
|
75
80
|
'/compact 压缩当前会话的较早上下文',
|
|
76
81
|
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
|
|
77
|
-
'/workspace
|
|
82
|
+
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
78
83
|
'/workspacelist 列出工作区绝对路径',
|
|
79
84
|
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
80
85
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
@@ -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,16 +1123,20 @@ 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);
|
|
1130
|
+
let contextEnhanced = false;
|
|
999
1131
|
if (snapshot) {
|
|
1000
|
-
|
|
1132
|
+
const originalContent = content ?? text;
|
|
1133
|
+
content = enhanceContextContent(originalContent, snapshot, () => ({
|
|
1001
1134
|
channel: 'dingtalk',
|
|
1002
1135
|
senderId: sender,
|
|
1003
1136
|
senderName: message.senderNick,
|
|
1137
|
+
conversationTitle: message.conversationTitle,
|
|
1004
1138
|
}));
|
|
1139
|
+
contextEnhanced = content !== originalContent;
|
|
1005
1140
|
}
|
|
1006
1141
|
if (typeof this.#api.createAiCard === 'function'
|
|
1007
1142
|
&& typeof this.#api.updateAiCard === 'function'
|
|
@@ -1014,13 +1149,17 @@ export class DingtalkHarnessBridge {
|
|
|
1014
1149
|
signal: this.#signal,
|
|
1015
1150
|
logger: this.#logger,
|
|
1016
1151
|
});
|
|
1152
|
+
const startedAt = Date.now();
|
|
1017
1153
|
cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
|
|
1154
|
+
if (cardStarted) cardStartedAt = startedAt;
|
|
1018
1155
|
}
|
|
1019
1156
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
1020
1157
|
harness: this.#harness,
|
|
1021
1158
|
state: this.#state,
|
|
1022
1159
|
key,
|
|
1023
|
-
|
|
1160
|
+
text,
|
|
1161
|
+
content,
|
|
1162
|
+
contextEnhanced,
|
|
1024
1163
|
createOptions: { signal: this.#signal },
|
|
1025
1164
|
existsOptions: { signal: this.#signal },
|
|
1026
1165
|
askOptions: {
|
|
@@ -1050,12 +1189,14 @@ export class DingtalkHarnessBridge {
|
|
|
1050
1189
|
let textDeliveryError = null;
|
|
1051
1190
|
let textReceipt = null;
|
|
1052
1191
|
let streamed = false;
|
|
1192
|
+
const deliveryStartedAt = cardStartedAt ?? Date.now();
|
|
1053
1193
|
try {
|
|
1054
1194
|
streamed = cardStarted && await cardStream.finish(answerText);
|
|
1055
1195
|
if (streamed) {
|
|
1056
1196
|
textReceipt = createDeliveryReceipt({
|
|
1057
1197
|
deliveryId: messageId,
|
|
1058
1198
|
presentation: 'dingtalk-card',
|
|
1199
|
+
providerMessageIds: cardStream.providerMessageIds,
|
|
1059
1200
|
});
|
|
1060
1201
|
} else {
|
|
1061
1202
|
textReceipt = createDeliveryReceipt({
|
|
@@ -1064,6 +1205,17 @@ export class DingtalkHarnessBridge {
|
|
|
1064
1205
|
providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
|
|
1065
1206
|
});
|
|
1066
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
|
+
}
|
|
1067
1219
|
} catch (error) {
|
|
1068
1220
|
textDeliveryError = channelDeliveryFailure(error);
|
|
1069
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) {
|