@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
|
@@ -18,6 +18,7 @@ const SESSION_BIND_USAGE = '用法:/session Session ID 或当前工作区序
|
|
|
18
18
|
const SESSION_LIST_USAGE = [
|
|
19
19
|
'用法:',
|
|
20
20
|
'/sessionlist 列出当前工作区会话',
|
|
21
|
+
'/sessionlist --limit N 列出当前工作区前 N 个会话(N 为正整数)',
|
|
21
22
|
'/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
|
|
22
23
|
'/sessionlist 工作区绝对路径 列出指定工作区会话',
|
|
23
24
|
].join('\n');
|
|
@@ -45,6 +46,20 @@ function validSessionId(value) {
|
|
|
45
46
|
&& !UNSAFE_DISPLAY_TEXT.test(value);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
export function parseSessionListArgument(value) {
|
|
50
|
+
const argument = typeof value === 'string' ? value.trim() : '';
|
|
51
|
+
if (!argument) return { selector: '', limit: null };
|
|
52
|
+
if (!argument.toLowerCase().startsWith('--limit')) {
|
|
53
|
+
return { selector: argument, limit: null };
|
|
54
|
+
}
|
|
55
|
+
const match = /^--limit[ \t]+(\d+)$/iu.exec(argument);
|
|
56
|
+
const limit = match ? Number(match[1]) : null;
|
|
57
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
58
|
+
return { error: t(SESSION_LIST_USAGE) };
|
|
59
|
+
}
|
|
60
|
+
return { selector: '', limit };
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
async function existingWorkspacePaths(values) {
|
|
49
64
|
const checked = await Promise.all(values.map(async (value) => {
|
|
50
65
|
const workspace = normalizedWorkspacePath(value);
|
|
@@ -235,12 +250,13 @@ async function currentSessionListWorkspace(harness) {
|
|
|
235
250
|
}
|
|
236
251
|
|
|
237
252
|
async function runSessionListCommand(match, harness) {
|
|
253
|
+
const request = parseSessionListArgument(match[1]);
|
|
254
|
+
if (request.error) return commandResult(request.error);
|
|
238
255
|
if (typeof harness?.listWorkspaceSessions !== 'function') {
|
|
239
256
|
return commandResult(t('当前机器人暂不支持列出工作区会话。'));
|
|
240
257
|
}
|
|
241
|
-
const selector = match[1]?.trim() ?? '';
|
|
242
258
|
try {
|
|
243
|
-
const resolved = await resolveSessionListWorkspace(selector, harness);
|
|
259
|
+
const resolved = await resolveSessionListWorkspace(request.selector, harness);
|
|
244
260
|
if (resolved.error) return commandResult(resolved.error);
|
|
245
261
|
const listed = await harness.listWorkspaceSessions(resolved.workspace);
|
|
246
262
|
if (!listed || !Array.isArray(listed.sessions)) {
|
|
@@ -249,7 +265,10 @@ async function runSessionListCommand(match, harness) {
|
|
|
249
265
|
harness.assertWorkspaceScope?.();
|
|
250
266
|
const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
|
|
251
267
|
const currentWorkspace = await currentSessionListWorkspace(harness);
|
|
252
|
-
const
|
|
268
|
+
const sessions = request.limit === null
|
|
269
|
+
? listed.sessions
|
|
270
|
+
: listed.sessions.slice(0, request.limit);
|
|
271
|
+
const message = sessionListMessage(workspace, sessions, {
|
|
253
272
|
currentWorkspace: workspace === currentWorkspace,
|
|
254
273
|
});
|
|
255
274
|
return commandResult(message, splitWorkspaceCommandMessage(message));
|
|
@@ -246,6 +246,24 @@ export class SlackApi {
|
|
|
246
246
|
return value.file;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
async getMessage({ channelId, messageTs, signal } = {}) {
|
|
250
|
+
const timestamp = requiredString(messageTs, 'message timestamp');
|
|
251
|
+
const value = await this.#request('conversations.history', {
|
|
252
|
+
tokenKind: 'bot',
|
|
253
|
+
signal,
|
|
254
|
+
body: {
|
|
255
|
+
channel: slackId(channelId, 'channel id'),
|
|
256
|
+
oldest: timestamp,
|
|
257
|
+
latest: timestamp,
|
|
258
|
+
inclusive: true,
|
|
259
|
+
limit: 1,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
return Array.isArray(value?.messages)
|
|
263
|
+
? value.messages.find((message) => String(message?.ts ?? '') === timestamp) ?? null
|
|
264
|
+
: null;
|
|
265
|
+
}
|
|
266
|
+
|
|
249
267
|
postMessage({ channelId, text, threadTs, signal }) {
|
|
250
268
|
return this.#request('chat.postMessage', {
|
|
251
269
|
tokenKind: 'bot',
|
|
@@ -49,6 +49,58 @@ function slackFileUrl(file) {
|
|
|
49
49
|
? file.url_private_download : file?.url_private;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function slackReplyAttachment(file) {
|
|
53
|
+
if (!file || typeof file !== 'object') return null;
|
|
54
|
+
const mediaType = typeof file.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
|
55
|
+
const kind = mediaType.startsWith('image/') ? 'image'
|
|
56
|
+
: mediaType.startsWith('audio/') ? 'audio'
|
|
57
|
+
: mediaType.startsWith('video/') ? 'video' : 'file';
|
|
58
|
+
const name = typeof file.name === 'string' && file.name
|
|
59
|
+
? file.name : typeof file.title === 'string' && file.title ? file.title : undefined;
|
|
60
|
+
return { kind, ...(name ? { name } : {}) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function slackReplySnapshot(message, messageTs) {
|
|
64
|
+
if (!message || String(message.ts ?? '') !== messageTs) return null;
|
|
65
|
+
const authorId = typeof message.user === 'string' && message.user
|
|
66
|
+
? message.user : typeof message.bot_id === 'string' && message.bot_id
|
|
67
|
+
? message.bot_id : undefined;
|
|
68
|
+
const authorName = typeof message.username === 'string' && message.username
|
|
69
|
+
? message.username : undefined;
|
|
70
|
+
return {
|
|
71
|
+
messageId: messageTs,
|
|
72
|
+
...(authorId ? { authorId } : {}),
|
|
73
|
+
...(authorName ? { authorName } : {}),
|
|
74
|
+
content: decodeSlackText(message.text ?? ''),
|
|
75
|
+
attachments: Array.isArray(message.files)
|
|
76
|
+
? message.files.map(slackReplyAttachment).filter(Boolean)
|
|
77
|
+
: [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function slackThreadReplyReference(event, loadReply) {
|
|
82
|
+
const messageTs = typeof event?.thread_ts === 'string' ? event.thread_ts : '';
|
|
83
|
+
if (!messageTs || messageTs === String(event?.ts ?? '')) return undefined;
|
|
84
|
+
return {
|
|
85
|
+
messageId: messageTs,
|
|
86
|
+
load: async ({ signal } = {}) => {
|
|
87
|
+
try {
|
|
88
|
+
const message = await loadReply({
|
|
89
|
+
channelId: String(event.channel),
|
|
90
|
+
messageTs,
|
|
91
|
+
signal,
|
|
92
|
+
});
|
|
93
|
+
return slackReplySnapshot(message, messageTs);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code === 'slack-missing-scope') {
|
|
96
|
+
return { messageId: messageTs, unavailableReason: 'permission-denied' };
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
function slackImageSource(file, loadFile) {
|
|
53
105
|
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
|
54
106
|
const url = slackFileUrl(file);
|
|
@@ -104,6 +156,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|
|
104
156
|
loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
|
|
105
157
|
loadFileStream = loadFile,
|
|
106
158
|
loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
|
|
159
|
+
loadReply = async () => { throw new Error('Slack reply loader is unavailable'); },
|
|
107
160
|
} = {}) {
|
|
108
161
|
const event = payload?.event;
|
|
109
162
|
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
|
@@ -112,6 +165,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|
|
112
165
|
if (!direct && !mentioned) return null;
|
|
113
166
|
if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
|
|
114
167
|
const threadTs = String(event.thread_ts ?? event.ts);
|
|
168
|
+
const replyTo = slackThreadReplyReference(event, loadReply);
|
|
115
169
|
return {
|
|
116
170
|
messageId: String(payload.event_id),
|
|
117
171
|
senderId: String(event.user),
|
|
@@ -126,6 +180,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|
|
126
180
|
files: Array.isArray(event.files)
|
|
127
181
|
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
|
|
128
182
|
: [],
|
|
183
|
+
...(replyTo ? { replyTo } : {}),
|
|
129
184
|
addressed: direct || mentioned,
|
|
130
185
|
reactionTarget: {
|
|
131
186
|
channelId: String(event.channel),
|
|
@@ -555,6 +610,7 @@ export class SlackRuntime {
|
|
|
555
610
|
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
|
556
611
|
loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
|
|
557
612
|
loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
|
|
613
|
+
loadReply: (options) => this.#api.getMessage(options),
|
|
558
614
|
});
|
|
559
615
|
const bridge = this.#bridge;
|
|
560
616
|
if (message && bridge) {
|
|
@@ -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),
|
|
@@ -170,12 +269,12 @@ export function normalizeTelegramUpdate(update, {
|
|
|
170
269
|
}),
|
|
171
270
|
senderIsBot: message.from?.is_bot === true,
|
|
172
271
|
kind: direct ? 'direct' : 'group',
|
|
173
|
-
conversationId
|
|
174
|
-
? String(chatId) : `${chatId}:${messageThreadId}`,
|
|
272
|
+
conversationId,
|
|
175
273
|
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
|
176
274
|
plainText: typeof message.text === 'string',
|
|
177
275
|
images: image ? [image] : [],
|
|
178
276
|
files: file ? [file] : [],
|
|
277
|
+
...(replyTo ? { replyTo } : {}),
|
|
179
278
|
addressed,
|
|
180
279
|
reactionTarget: { chatId, messageId },
|
|
181
280
|
replyTarget: {
|
|
@@ -839,6 +938,21 @@ export class TelegramRuntime {
|
|
|
839
938
|
}
|
|
840
939
|
}
|
|
841
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
|
+
|
|
842
956
|
async #poll(initialCursor, signal) {
|
|
843
957
|
let cursor = initialCursor;
|
|
844
958
|
while (!signal.aborted) {
|
|
@@ -863,6 +977,7 @@ export class TelegramRuntime {
|
|
|
863
977
|
username: this.#config.username,
|
|
864
978
|
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
|
865
979
|
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
|
|
980
|
+
loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
|
|
866
981
|
});
|
|
867
982
|
if (message) {
|
|
868
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';
|
|
@@ -71,6 +74,7 @@ function helpText() {
|
|
|
71
74
|
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
|
72
75
|
t('/workspacelist 列出工作区绝对路径'),
|
|
73
76
|
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
|
77
|
+
t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
|
|
74
78
|
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
|
75
79
|
t('/models 按序号列出所有可用模型'),
|
|
76
80
|
t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
|
|
@@ -108,8 +112,7 @@ function conversationKey(frame) {
|
|
|
108
112
|
return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
function
|
|
112
|
-
const body = bodyOf(frame);
|
|
115
|
+
function messageContentText(body) {
|
|
113
116
|
let text = '';
|
|
114
117
|
if (body.msgtype === 'text') {
|
|
115
118
|
text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
|
|
@@ -122,6 +125,12 @@ function messageText(frame) {
|
|
|
122
125
|
.join('\n')
|
|
123
126
|
.trim();
|
|
124
127
|
}
|
|
128
|
+
return text;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function messageText(frame) {
|
|
132
|
+
const body = bodyOf(frame);
|
|
133
|
+
const text = messageContentText(body);
|
|
125
134
|
// Group callbacks retain the leading @bot mention that caused delivery.
|
|
126
135
|
// It is routing metadata rather than part of the user's prompt or answer.
|
|
127
136
|
return body.chattype === 'group'
|
|
@@ -145,6 +154,36 @@ function fileContents(frame) {
|
|
|
145
154
|
: [];
|
|
146
155
|
}
|
|
147
156
|
|
|
157
|
+
function quoteAttachments(quote) {
|
|
158
|
+
if (quote?.msgtype === 'image') return [{ kind: 'image' }];
|
|
159
|
+
if (quote?.msgtype === 'voice') return [{ kind: 'audio' }];
|
|
160
|
+
if (quote?.msgtype === 'file') {
|
|
161
|
+
const name = nonEmptyString(
|
|
162
|
+
quote.file?.filename ?? quote.file?.file_name ?? quote.file?.name,
|
|
163
|
+
);
|
|
164
|
+
return [{ kind: 'file', ...(name ? { name } : {}) }];
|
|
165
|
+
}
|
|
166
|
+
if (quote?.msgtype !== 'mixed' || !Array.isArray(quote.mixed?.msg_item)) return [];
|
|
167
|
+
return quote.mixed.msg_item
|
|
168
|
+
.filter((item) => item?.msgtype === 'image')
|
|
169
|
+
.map(() => ({ kind: 'image' }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function replyReferenceForBody(body) {
|
|
173
|
+
const quote = body?.quote;
|
|
174
|
+
if (!quote || typeof quote !== 'object') return null;
|
|
175
|
+
const content = messageContentText(quote);
|
|
176
|
+
const attachments = quoteAttachments(quote);
|
|
177
|
+
const supported = ['text', 'image', 'mixed', 'voice', 'file'].includes(quote.msgtype);
|
|
178
|
+
return {
|
|
179
|
+
...(content ? { content } : {}),
|
|
180
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
181
|
+
...(!content && attachments.length === 0
|
|
182
|
+
? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
|
|
183
|
+
: {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
148
187
|
function imageSource(client, image) {
|
|
149
188
|
const url = nonEmptyString(image?.url);
|
|
150
189
|
if (!url) return null;
|
|
@@ -200,10 +239,13 @@ function fileSource(client, file) {
|
|
|
200
239
|
}
|
|
201
240
|
|
|
202
241
|
export function wecomInboundMessage(frame, client) {
|
|
242
|
+
const body = bodyOf(frame);
|
|
243
|
+
const replyTo = replyReferenceForBody(body);
|
|
203
244
|
return {
|
|
204
245
|
content: messageText(frame),
|
|
205
246
|
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
|
206
247
|
files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
|
|
248
|
+
...(replyTo ? { replyTo } : {}),
|
|
207
249
|
};
|
|
208
250
|
}
|
|
209
251
|
|
|
@@ -604,7 +646,7 @@ export class WecomHarnessBridge {
|
|
|
604
646
|
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
|
605
647
|
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
|
606
648
|
: this.#batchInputs.handle(key, commandText, {
|
|
607
|
-
plainText: isNativeWecomText(frame),
|
|
649
|
+
plainText: isNativeWecomText(frame) && !hasReplyReference(commandMessage),
|
|
608
650
|
});
|
|
609
651
|
if (result.handled) {
|
|
610
652
|
if (result.kind === 'submit') {
|
|
@@ -934,6 +976,7 @@ export class WecomHarnessBridge {
|
|
|
934
976
|
const text = message.content;
|
|
935
977
|
const hasImages = hasInboundImages(message);
|
|
936
978
|
const hasFiles = hasInboundFiles(message);
|
|
979
|
+
const hasReply = hasReplyReference(message);
|
|
937
980
|
const key = conversationKey(frame);
|
|
938
981
|
let streamId = null;
|
|
939
982
|
let streamStarted = false;
|
|
@@ -942,7 +985,7 @@ export class WecomHarnessBridge {
|
|
|
942
985
|
let batchSettled = batchSubmission === null;
|
|
943
986
|
let promptRecorded = false;
|
|
944
987
|
try {
|
|
945
|
-
if (!text && !hasImages && !hasFiles) {
|
|
988
|
+
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
946
989
|
await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
|
|
947
990
|
await this.#state.markSeen(messageId);
|
|
948
991
|
return;
|
|
@@ -1003,8 +1046,8 @@ export class WecomHarnessBridge {
|
|
|
1003
1046
|
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
|
|
1004
1047
|
}
|
|
1005
1048
|
|
|
1006
|
-
let content = hasImages
|
|
1007
|
-
? await
|
|
1049
|
+
let content = hasImages || hasReply
|
|
1050
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
1008
1051
|
: undefined;
|
|
1009
1052
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
1010
1053
|
let contextEnhanced = false;
|
|
@@ -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
|
}
|