@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
|
@@ -4,6 +4,7 @@ import { createEditableMessageStream, splitMessageText } from '../shared/editabl
|
|
|
4
4
|
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
|
5
5
|
import { t } from '../shared/i18n.mjs';
|
|
6
6
|
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
|
7
|
+
import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
|
|
7
8
|
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
|
|
8
9
|
import { createTelegramHttpTransport } from './telegram-http.mjs';
|
|
9
10
|
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
|
@@ -138,11 +139,94 @@ function telegramFileSource(message, loadFile) {
|
|
|
138
139
|
};
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
function telegramReplyAttachment(kind, file, fallbackName) {
|
|
143
|
+
if (!file || typeof file !== 'object') return null;
|
|
144
|
+
const name = typeof file.file_name === 'string' && file.file_name
|
|
145
|
+
? file.file_name : typeof fallbackName === 'string' && fallbackName
|
|
146
|
+
? fallbackName : undefined;
|
|
147
|
+
return { kind, ...(name ? { name } : {}) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function telegramReplyAttachments(message) {
|
|
151
|
+
const attachments = [];
|
|
152
|
+
if (Array.isArray(message?.photo) && message.photo.length > 0) {
|
|
153
|
+
const largest = message.photo.reduce((best, candidate) => (
|
|
154
|
+
photoScore(candidate) > photoScore(best) ? candidate : best
|
|
155
|
+
));
|
|
156
|
+
attachments.push(telegramReplyAttachment(
|
|
157
|
+
'image',
|
|
158
|
+
largest,
|
|
159
|
+
`${largest.file_unique_id ?? largest.file_id ?? 'telegram-photo'}.jpg`,
|
|
160
|
+
));
|
|
161
|
+
} else if (message?.document) {
|
|
162
|
+
attachments.push(telegramReplyAttachment(
|
|
163
|
+
imageTypeForDocument(message.document) ? 'image' : 'file',
|
|
164
|
+
message.document,
|
|
165
|
+
));
|
|
166
|
+
}
|
|
167
|
+
for (const [field, kind] of [
|
|
168
|
+
['audio', 'audio'],
|
|
169
|
+
['voice', 'audio'],
|
|
170
|
+
['video', 'video'],
|
|
171
|
+
['video_note', 'video'],
|
|
172
|
+
['animation', 'video'],
|
|
173
|
+
]) {
|
|
174
|
+
if (message?.[field]) attachments.push(telegramReplyAttachment(kind, message[field]));
|
|
175
|
+
}
|
|
176
|
+
if (message?.sticker) {
|
|
177
|
+
attachments.push(telegramReplyAttachment(
|
|
178
|
+
message.sticker.is_video === true ? 'video' : 'image',
|
|
179
|
+
message.sticker,
|
|
180
|
+
message.sticker.file_unique_id ?? message.sticker.file_id,
|
|
181
|
+
));
|
|
182
|
+
}
|
|
183
|
+
return attachments.filter(Boolean);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function telegramReplyReference(message, { quote, loadReplyContent } = {}) {
|
|
187
|
+
if ((!message || typeof message !== 'object')
|
|
188
|
+
&& (!quote || typeof quote !== 'object')) return undefined;
|
|
189
|
+
const authorId = message?.from?.id === undefined ? undefined : String(message.from.id);
|
|
190
|
+
const authorName = [message?.from?.first_name, message?.from?.last_name]
|
|
191
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
192
|
+
.map((value) => value.trim()).join(' ') || (
|
|
193
|
+
typeof message?.from?.username === 'string' && message.from.username
|
|
194
|
+
? message.from.username : undefined
|
|
195
|
+
);
|
|
196
|
+
const content = typeof message?.text === 'string'
|
|
197
|
+
? message.text : typeof message?.caption === 'string'
|
|
198
|
+
? message.caption : typeof quote?.text === 'string' ? quote.text : '';
|
|
199
|
+
const attachments = telegramReplyAttachments(message);
|
|
200
|
+
const messageId = Number.isSafeInteger(message?.message_id)
|
|
201
|
+
? String(message.message_id) : undefined;
|
|
202
|
+
const createdAt = Number.isSafeInteger(message?.date) && message.date >= 0
|
|
203
|
+
? message.date * 1_000 : undefined;
|
|
204
|
+
const load = !content.trim() && attachments.length === 0
|
|
205
|
+
&& typeof loadReplyContent === 'function'
|
|
206
|
+
? ({ signal } = {}) => loadReplyContent({
|
|
207
|
+
...(messageId ? { messageId } : {}),
|
|
208
|
+
...(createdAt === undefined ? {} : { createdAt }),
|
|
209
|
+
}, { signal })
|
|
210
|
+
: null;
|
|
211
|
+
return {
|
|
212
|
+
...(messageId ? { messageId } : {}),
|
|
213
|
+
...(authorId ? { authorId } : {}),
|
|
214
|
+
...(authorName ? { authorName } : {}),
|
|
215
|
+
...(content.trim() ? { content } : {}),
|
|
216
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
217
|
+
...(load ? { load } : {}),
|
|
218
|
+
...(!content.trim() && attachments.length === 0 && !load
|
|
219
|
+
? { unavailableReason: 'not-delivered' }
|
|
220
|
+
: {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
141
224
|
export function normalizeTelegramUpdate(update, {
|
|
142
225
|
botId,
|
|
143
226
|
username,
|
|
144
227
|
loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
|
|
145
228
|
loadFileStream = loadFile,
|
|
229
|
+
loadReplyContent,
|
|
146
230
|
}) {
|
|
147
231
|
const message = update?.message;
|
|
148
232
|
const chatId = message?.chat?.id;
|
|
@@ -159,6 +243,21 @@ export function normalizeTelegramUpdate(update, {
|
|
|
159
243
|
? message.message_thread_id : undefined;
|
|
160
244
|
const image = telegramImageSource(message, loadFile);
|
|
161
245
|
const file = telegramFileSource(message, loadFileStream);
|
|
246
|
+
const conversationId = messageThreadId === undefined
|
|
247
|
+
? String(chatId) : `${chatId}:${messageThreadId}`;
|
|
248
|
+
const key = `${direct ? 'direct' : 'group'}:${conversationId}`;
|
|
249
|
+
const replyTo = telegramReplyReference(
|
|
250
|
+
message.reply_to_message ?? message.external_reply,
|
|
251
|
+
{
|
|
252
|
+
quote: message.quote,
|
|
253
|
+
...(typeof loadReplyContent === 'function' ? {
|
|
254
|
+
loadReplyContent: (reference, options) => loadReplyContent({
|
|
255
|
+
conversationKey: key,
|
|
256
|
+
...reference,
|
|
257
|
+
}, options),
|
|
258
|
+
} : {}),
|
|
259
|
+
},
|
|
260
|
+
);
|
|
162
261
|
return {
|
|
163
262
|
messageId: String(update.update_id),
|
|
164
263
|
senderId: String(senderId),
|
|
@@ -166,15 +265,16 @@ export function normalizeTelegramUpdate(update, {
|
|
|
166
265
|
senderName: [message.from?.first_name, message.from?.last_name]
|
|
167
266
|
.filter((value) => typeof value === 'string' && value.trim())
|
|
168
267
|
.map((value) => value.trim()).join(' ') || message.from?.username,
|
|
268
|
+
conversationTitle: direct ? undefined : message.chat?.title,
|
|
169
269
|
}),
|
|
170
270
|
senderIsBot: message.from?.is_bot === true,
|
|
171
271
|
kind: direct ? 'direct' : 'group',
|
|
172
|
-
conversationId
|
|
173
|
-
? String(chatId) : `${chatId}:${messageThreadId}`,
|
|
272
|
+
conversationId,
|
|
174
273
|
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
|
175
274
|
plainText: typeof message.text === 'string',
|
|
176
275
|
images: image ? [image] : [],
|
|
177
276
|
files: file ? [file] : [],
|
|
277
|
+
...(replyTo ? { replyTo } : {}),
|
|
178
278
|
addressed,
|
|
179
279
|
reactionTarget: { chatId, messageId },
|
|
180
280
|
replyTarget: {
|
|
@@ -838,6 +938,21 @@ export class TelegramRuntime {
|
|
|
838
938
|
}
|
|
839
939
|
}
|
|
840
940
|
|
|
941
|
+
async #loadReplyContent(reference, { signal } = {}) {
|
|
942
|
+
const key = typeof reference?.conversationKey === 'string'
|
|
943
|
+
? reference.conversationKey.trim() : '';
|
|
944
|
+
const quotedAt = Number(reference?.createdAt);
|
|
945
|
+
if (!key || !Number.isFinite(quotedAt)) {
|
|
946
|
+
return { unavailableReason: 'not-delivered' };
|
|
947
|
+
}
|
|
948
|
+
const sessionId = this.#state.sessionFor(key);
|
|
949
|
+
const session = typeof sessionId === 'string' && sessionId
|
|
950
|
+
? this.#harness.workspaceSession?.(sessionId)
|
|
951
|
+
: null;
|
|
952
|
+
const text = await recoverAssistantTextByTimestamp({ session, quotedAt, signal });
|
|
953
|
+
return text ? { content: text } : { unavailableReason: 'not-delivered' };
|
|
954
|
+
}
|
|
955
|
+
|
|
841
956
|
async #poll(initialCursor, signal) {
|
|
842
957
|
let cursor = initialCursor;
|
|
843
958
|
while (!signal.aborted) {
|
|
@@ -862,6 +977,7 @@ export class TelegramRuntime {
|
|
|
862
977
|
username: this.#config.username,
|
|
863
978
|
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
|
864
979
|
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
|
|
980
|
+
loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
|
|
865
981
|
});
|
|
866
982
|
if (message) {
|
|
867
983
|
void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
|
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
ImagePromptError,
|
|
34
34
|
imagePromptDiagnostic,
|
|
35
35
|
imagePromptUserMessage,
|
|
36
|
-
promptContentForMessage,
|
|
37
36
|
} from '../shared/image-prompt.mjs';
|
|
38
37
|
import {
|
|
39
38
|
hasInboundFiles,
|
|
@@ -42,6 +41,10 @@ import {
|
|
|
42
41
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
43
42
|
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
|
44
43
|
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
|
44
|
+
import {
|
|
45
|
+
hasReplyReference,
|
|
46
|
+
promptContentForInboundMessage,
|
|
47
|
+
} from '../shared/semantic/reply-reference.mjs';
|
|
45
48
|
import {
|
|
46
49
|
createDeliveryReceipt,
|
|
47
50
|
} from '../shared/semantic/delivery.mjs';
|
|
@@ -68,7 +71,7 @@ function helpText() {
|
|
|
68
71
|
t('/new 开启一个全新会话'),
|
|
69
72
|
t('/compact 压缩当前会话的较早上下文'),
|
|
70
73
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
71
|
-
t('/workspace
|
|
74
|
+
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
|
72
75
|
t('/workspacelist 列出工作区绝对路径'),
|
|
73
76
|
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
|
74
77
|
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
|
@@ -108,8 +111,7 @@ function conversationKey(frame) {
|
|
|
108
111
|
return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
function
|
|
112
|
-
const body = bodyOf(frame);
|
|
114
|
+
function messageContentText(body) {
|
|
113
115
|
let text = '';
|
|
114
116
|
if (body.msgtype === 'text') {
|
|
115
117
|
text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
|
|
@@ -122,6 +124,12 @@ function messageText(frame) {
|
|
|
122
124
|
.join('\n')
|
|
123
125
|
.trim();
|
|
124
126
|
}
|
|
127
|
+
return text;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function messageText(frame) {
|
|
131
|
+
const body = bodyOf(frame);
|
|
132
|
+
const text = messageContentText(body);
|
|
125
133
|
// Group callbacks retain the leading @bot mention that caused delivery.
|
|
126
134
|
// It is routing metadata rather than part of the user's prompt or answer.
|
|
127
135
|
return body.chattype === 'group'
|
|
@@ -145,6 +153,36 @@ function fileContents(frame) {
|
|
|
145
153
|
: [];
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
function quoteAttachments(quote) {
|
|
157
|
+
if (quote?.msgtype === 'image') return [{ kind: 'image' }];
|
|
158
|
+
if (quote?.msgtype === 'voice') return [{ kind: 'audio' }];
|
|
159
|
+
if (quote?.msgtype === 'file') {
|
|
160
|
+
const name = nonEmptyString(
|
|
161
|
+
quote.file?.filename ?? quote.file?.file_name ?? quote.file?.name,
|
|
162
|
+
);
|
|
163
|
+
return [{ kind: 'file', ...(name ? { name } : {}) }];
|
|
164
|
+
}
|
|
165
|
+
if (quote?.msgtype !== 'mixed' || !Array.isArray(quote.mixed?.msg_item)) return [];
|
|
166
|
+
return quote.mixed.msg_item
|
|
167
|
+
.filter((item) => item?.msgtype === 'image')
|
|
168
|
+
.map(() => ({ kind: 'image' }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function replyReferenceForBody(body) {
|
|
172
|
+
const quote = body?.quote;
|
|
173
|
+
if (!quote || typeof quote !== 'object') return null;
|
|
174
|
+
const content = messageContentText(quote);
|
|
175
|
+
const attachments = quoteAttachments(quote);
|
|
176
|
+
const supported = ['text', 'image', 'mixed', 'voice', 'file'].includes(quote.msgtype);
|
|
177
|
+
return {
|
|
178
|
+
...(content ? { content } : {}),
|
|
179
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
180
|
+
...(!content && attachments.length === 0
|
|
181
|
+
? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
|
|
182
|
+
: {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
148
186
|
function imageSource(client, image) {
|
|
149
187
|
const url = nonEmptyString(image?.url);
|
|
150
188
|
if (!url) return null;
|
|
@@ -200,10 +238,13 @@ function fileSource(client, file) {
|
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
export function wecomInboundMessage(frame, client) {
|
|
241
|
+
const body = bodyOf(frame);
|
|
242
|
+
const replyTo = replyReferenceForBody(body);
|
|
203
243
|
return {
|
|
204
244
|
content: messageText(frame),
|
|
205
245
|
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
|
206
246
|
files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
|
|
247
|
+
...(replyTo ? { replyTo } : {}),
|
|
207
248
|
};
|
|
208
249
|
}
|
|
209
250
|
|
|
@@ -604,7 +645,7 @@ export class WecomHarnessBridge {
|
|
|
604
645
|
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
|
605
646
|
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
|
606
647
|
: this.#batchInputs.handle(key, commandText, {
|
|
607
|
-
plainText: isNativeWecomText(frame),
|
|
648
|
+
plainText: isNativeWecomText(frame) && !hasReplyReference(commandMessage),
|
|
608
649
|
});
|
|
609
650
|
if (result.handled) {
|
|
610
651
|
if (result.kind === 'submit') {
|
|
@@ -934,6 +975,7 @@ export class WecomHarnessBridge {
|
|
|
934
975
|
const text = message.content;
|
|
935
976
|
const hasImages = hasInboundImages(message);
|
|
936
977
|
const hasFiles = hasInboundFiles(message);
|
|
978
|
+
const hasReply = hasReplyReference(message);
|
|
937
979
|
const key = conversationKey(frame);
|
|
938
980
|
let streamId = null;
|
|
939
981
|
let streamStarted = false;
|
|
@@ -942,7 +984,7 @@ export class WecomHarnessBridge {
|
|
|
942
984
|
let batchSettled = batchSubmission === null;
|
|
943
985
|
let promptRecorded = false;
|
|
944
986
|
try {
|
|
945
|
-
if (!text && !hasImages && !hasFiles) {
|
|
987
|
+
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
946
988
|
await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
|
|
947
989
|
await this.#state.markSeen(messageId);
|
|
948
990
|
return;
|
|
@@ -1003,15 +1045,18 @@ export class WecomHarnessBridge {
|
|
|
1003
1045
|
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
|
|
1004
1046
|
}
|
|
1005
1047
|
|
|
1006
|
-
let content = hasImages
|
|
1007
|
-
? await
|
|
1048
|
+
let content = hasImages || hasReply
|
|
1049
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
1008
1050
|
: undefined;
|
|
1009
1051
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
1052
|
+
let contextEnhanced = false;
|
|
1010
1053
|
if (snapshot) {
|
|
1011
|
-
|
|
1054
|
+
const originalContent = content ?? text;
|
|
1055
|
+
content = enhanceContextContent(originalContent, snapshot, () => ({
|
|
1012
1056
|
channel: 'wecom',
|
|
1013
1057
|
senderId,
|
|
1014
1058
|
}));
|
|
1059
|
+
contextEnhanced = content !== originalContent;
|
|
1015
1060
|
}
|
|
1016
1061
|
await this.#state.markSeen(messageId);
|
|
1017
1062
|
promptRecorded = true;
|
|
@@ -1021,6 +1066,7 @@ export class WecomHarnessBridge {
|
|
|
1021
1066
|
key,
|
|
1022
1067
|
text,
|
|
1023
1068
|
content,
|
|
1069
|
+
contextEnhanced,
|
|
1024
1070
|
createOptions: { signal: this.#signal },
|
|
1025
1071
|
existsOptions: { signal: this.#signal },
|
|
1026
1072
|
askOptions: {
|
|
@@ -1,13 +1,67 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { weixinMessageTimestampMs } from './weixin-api.mjs';
|
|
5
|
+
|
|
4
6
|
const EMPTY_STATE = Object.freeze({
|
|
5
7
|
version: 1,
|
|
6
8
|
sessions: {},
|
|
7
9
|
seenMessageIds: [],
|
|
8
10
|
getUpdatesBuf: '',
|
|
11
|
+
recentOutboundMessages: [],
|
|
9
12
|
});
|
|
10
13
|
|
|
14
|
+
export const WEIXIN_RECENT_OUTBOUND_LIMIT = 200;
|
|
15
|
+
export const WEIXIN_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
16
|
+
export const WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
|
|
17
|
+
export const WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
function nonEmptyString(value) {
|
|
20
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function timestamp(value) {
|
|
24
|
+
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
25
|
+
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function providerMessageIds(value) {
|
|
29
|
+
if (!Array.isArray(value)) return [];
|
|
30
|
+
return [...new Set(value
|
|
31
|
+
.map((id) => (id === undefined || id === null ? null : nonEmptyString(String(id))))
|
|
32
|
+
.filter(Boolean))];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncateText(value) {
|
|
36
|
+
const text = nonEmptyString(value);
|
|
37
|
+
return text ? [...text].slice(0, WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT).join('') : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeRecentOutboundMessage(value) {
|
|
41
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
42
|
+
const toUserId = nonEmptyString(value.toUserId);
|
|
43
|
+
const text = truncateText(value.text);
|
|
44
|
+
const sentAt = timestamp(value.sentAt);
|
|
45
|
+
const completedAt = timestamp(value.completedAt) ?? sentAt;
|
|
46
|
+
if (!toUserId || !text || sentAt === null || completedAt === null) return null;
|
|
47
|
+
return {
|
|
48
|
+
toUserId,
|
|
49
|
+
text,
|
|
50
|
+
sentAt,
|
|
51
|
+
completedAt: Math.max(sentAt, completedAt),
|
|
52
|
+
providerMessageIds: providerMessageIds(value.providerMessageIds),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recentOutboundMessages(value, now = Date.now()) {
|
|
57
|
+
if (!Array.isArray(value)) return [];
|
|
58
|
+
const cutoff = now - WEIXIN_RECENT_OUTBOUND_TTL_MS;
|
|
59
|
+
return value
|
|
60
|
+
.map(normalizeRecentOutboundMessage)
|
|
61
|
+
.filter((entry) => entry && entry.completedAt >= cutoff)
|
|
62
|
+
.slice(-WEIXIN_RECENT_OUTBOUND_LIMIT);
|
|
63
|
+
}
|
|
64
|
+
|
|
11
65
|
function normalizeState(value) {
|
|
12
66
|
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
|
13
67
|
const sessions = {};
|
|
@@ -25,6 +79,7 @@ function normalizeState(value) {
|
|
|
25
79
|
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
|
26
80
|
: [],
|
|
27
81
|
getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '',
|
|
82
|
+
recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
|
|
28
83
|
};
|
|
29
84
|
}
|
|
30
85
|
|
|
@@ -90,6 +145,61 @@ export class WeixinStateStore {
|
|
|
90
145
|
await this.#persist();
|
|
91
146
|
}
|
|
92
147
|
|
|
148
|
+
async rememberOutboundMessage({
|
|
149
|
+
toUserId,
|
|
150
|
+
text,
|
|
151
|
+
sentAt = Date.now(),
|
|
152
|
+
completedAt = Date.now(),
|
|
153
|
+
providerMessageIds: messageIds = [],
|
|
154
|
+
} = {}) {
|
|
155
|
+
const entry = normalizeRecentOutboundMessage({
|
|
156
|
+
toUserId,
|
|
157
|
+
text,
|
|
158
|
+
sentAt,
|
|
159
|
+
completedAt,
|
|
160
|
+
providerMessageIds: messageIds,
|
|
161
|
+
});
|
|
162
|
+
if (!entry) throw new TypeError('Invalid Weixin outbound message');
|
|
163
|
+
this.#state.recentOutboundMessages = recentOutboundMessages([
|
|
164
|
+
...this.#state.recentOutboundMessages,
|
|
165
|
+
entry,
|
|
166
|
+
]);
|
|
167
|
+
await this.#persist();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
recentOutboundTextFor({
|
|
171
|
+
toUserId,
|
|
172
|
+
messageId,
|
|
173
|
+
createTimeMs,
|
|
174
|
+
updateTimeMs,
|
|
175
|
+
now = Date.now(),
|
|
176
|
+
} = {}) {
|
|
177
|
+
const recipient = nonEmptyString(toUserId);
|
|
178
|
+
if (!recipient) return null;
|
|
179
|
+
const active = recentOutboundMessages(this.#state.recentOutboundMessages, now)
|
|
180
|
+
.filter((entry) => entry.toUserId === recipient);
|
|
181
|
+
const quotedMessageId = messageId === undefined || messageId === null
|
|
182
|
+
? null
|
|
183
|
+
: nonEmptyString(String(messageId));
|
|
184
|
+
if (quotedMessageId) {
|
|
185
|
+
const exact = active.filter((entry) => entry.providerMessageIds.includes(quotedMessageId));
|
|
186
|
+
if (exact.length === 1) return exact[0].text;
|
|
187
|
+
if (exact.length > 1) return null;
|
|
188
|
+
}
|
|
189
|
+
const quotedTimes = [
|
|
190
|
+
timestamp(createTimeMs),
|
|
191
|
+
timestamp(updateTimeMs),
|
|
192
|
+
weixinMessageTimestampMs(quotedMessageId, { now }),
|
|
193
|
+
]
|
|
194
|
+
.filter((value) => value !== null);
|
|
195
|
+
if (quotedTimes.length === 0) return null;
|
|
196
|
+
const candidates = active.filter((entry) => quotedTimes.some((quotedAt) => (
|
|
197
|
+
quotedAt >= entry.sentAt - WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
|
198
|
+
&& quotedAt <= entry.completedAt + WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
|
|
199
|
+
)));
|
|
200
|
+
return candidates.length === 1 ? candidates[0].text : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
93
203
|
snapshot() {
|
|
94
204
|
return structuredClone(this.#state);
|
|
95
205
|
}
|
|
@@ -21,6 +21,9 @@ const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
|
|
21
21
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
22
22
|
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
23
23
|
const WEIXIN_CDN_UPLOAD_RETRIES = 3;
|
|
24
|
+
const WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT = 22n;
|
|
25
|
+
const WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
|
|
26
|
+
const WEIXIN_MESSAGE_ID_MAX_FUTURE_MS = 24 * 60 * 60 * 1_000;
|
|
24
27
|
const LOGIN_STATUSES = new Set([
|
|
25
28
|
'wait',
|
|
26
29
|
'scaned',
|
|
@@ -689,6 +692,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
689
692
|
const recipient = nonEmptyString(toUserId);
|
|
690
693
|
const content = nonEmptyString(text);
|
|
691
694
|
if (!recipient || !content) throw new TypeError('toUserId and text are required');
|
|
695
|
+
const clientId = `dsh-weixin-${randomUUID()}`;
|
|
692
696
|
const response = await requestJson(fetchImpl, {
|
|
693
697
|
method: 'POST',
|
|
694
698
|
baseUrl,
|
|
@@ -699,7 +703,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
699
703
|
msg: {
|
|
700
704
|
from_user_id: '',
|
|
701
705
|
to_user_id: recipient,
|
|
702
|
-
client_id:
|
|
706
|
+
client_id: clientId,
|
|
703
707
|
message_type: 2,
|
|
704
708
|
message_state: 2,
|
|
705
709
|
item_list: [{ type: 1, text_item: { text: content } }],
|
|
@@ -717,7 +721,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
717
721
|
{ providerCode: sendRejection },
|
|
718
722
|
);
|
|
719
723
|
}
|
|
720
|
-
return
|
|
724
|
+
return {
|
|
725
|
+
...(response && typeof response === 'object' ? response : {}),
|
|
726
|
+
providerMessageIds: [clientId],
|
|
727
|
+
};
|
|
721
728
|
},
|
|
722
729
|
|
|
723
730
|
async sendFile(request) {
|
|
@@ -791,6 +798,83 @@ export function extractWeixinText(message) {
|
|
|
791
798
|
return null;
|
|
792
799
|
}
|
|
793
800
|
|
|
801
|
+
function weixinReplyAttachment(item) {
|
|
802
|
+
if (item?.type === 2 || (item?.image_item && typeof item.image_item === 'object')) {
|
|
803
|
+
return { kind: 'image' };
|
|
804
|
+
}
|
|
805
|
+
if (item?.type === 3 || (item?.voice_item && typeof item.voice_item === 'object')) {
|
|
806
|
+
return { kind: 'audio' };
|
|
807
|
+
}
|
|
808
|
+
if (item?.type === 4 || (item?.file_item && typeof item.file_item === 'object')) {
|
|
809
|
+
const name = nonEmptyString(item?.file_item?.file_name);
|
|
810
|
+
return { kind: 'file', ...(name ? { name } : {}) };
|
|
811
|
+
}
|
|
812
|
+
if (item?.type === 5 || (item?.video_item && typeof item.video_item === 'object')) {
|
|
813
|
+
return { kind: 'video' };
|
|
814
|
+
}
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function weixinQuotedText(item) {
|
|
819
|
+
const text = nonEmptyString(item?.text_item?.text);
|
|
820
|
+
if (text) return text;
|
|
821
|
+
return nonEmptyString(item?.voice_item?.text);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Extract the one-level reply snapshot embedded in an inbound iLink message item. */
|
|
825
|
+
export function extractWeixinReplyReference(message, { resolveContent, loadContent } = {}) {
|
|
826
|
+
const container = (message?.item_list ?? []).find((item) => (
|
|
827
|
+
item?.ref_msg && typeof item.ref_msg === 'object'
|
|
828
|
+
));
|
|
829
|
+
if (!container) return null;
|
|
830
|
+
const ref = container.ref_msg;
|
|
831
|
+
const quotedItem = ref.message_item && typeof ref.message_item === 'object'
|
|
832
|
+
? ref.message_item
|
|
833
|
+
: null;
|
|
834
|
+
const quotedText = weixinQuotedText(quotedItem);
|
|
835
|
+
let content = quotedText ?? nonEmptyString(ref.title);
|
|
836
|
+
const attachment = weixinReplyAttachment(quotedItem);
|
|
837
|
+
const messageId = quotedItem?.msg_id === undefined || quotedItem?.msg_id === null
|
|
838
|
+
? null
|
|
839
|
+
: nonEmptyString(String(quotedItem.msg_id));
|
|
840
|
+
const referenceDetails = {
|
|
841
|
+
messageId,
|
|
842
|
+
createTimeMs: quotedItem?.create_time_ms ?? ref.create_time_ms,
|
|
843
|
+
updateTimeMs: quotedItem?.update_time_ms ?? ref.update_time_ms,
|
|
844
|
+
};
|
|
845
|
+
if (!content && !attachment && typeof resolveContent === 'function') {
|
|
846
|
+
content = nonEmptyString(resolveContent(referenceDetails));
|
|
847
|
+
}
|
|
848
|
+
const load = !content && !attachment && typeof loadContent === 'function'
|
|
849
|
+
? (options) => loadContent(referenceDetails, options)
|
|
850
|
+
: null;
|
|
851
|
+
const knownType = quotedItem && [1, 2, 3, 4, 5, 8].includes(quotedItem.type);
|
|
852
|
+
return {
|
|
853
|
+
...(messageId ? { messageId } : {}),
|
|
854
|
+
...(content ? { content } : {}),
|
|
855
|
+
...(attachment ? { attachments: [attachment] } : {}),
|
|
856
|
+
...(load ? { load } : {}),
|
|
857
|
+
...(!content && !attachment && !load
|
|
858
|
+
? { unavailableReason: quotedItem && !knownType ? 'unsupported' : 'not-delivered' }
|
|
859
|
+
: {}),
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Decode the millisecond timestamp carried by current 64-bit iLink message IDs. */
|
|
864
|
+
export function weixinMessageTimestampMs(messageId, { now = Date.now() } = {}) {
|
|
865
|
+
const value = messageId === undefined || messageId === null ? '' : String(messageId).trim();
|
|
866
|
+
if (!/^\d{16,20}$/u.test(value)) return null;
|
|
867
|
+
try {
|
|
868
|
+
const timestampMs = Number(BigInt(value) >> WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT);
|
|
869
|
+
if (!Number.isSafeInteger(timestampMs)
|
|
870
|
+
|| timestampMs < WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS
|
|
871
|
+
|| timestampMs > now + WEIXIN_MESSAGE_ID_MAX_FUTURE_MS) return null;
|
|
872
|
+
return timestampMs;
|
|
873
|
+
} catch {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
794
878
|
export function weixinMessageId(message) {
|
|
795
879
|
if (message?.message_id !== undefined && message.message_id !== null) {
|
|
796
880
|
return String(message.message_id);
|