@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
|
@@ -206,12 +206,101 @@ function discordFileSource(attachment, fetchImpl) {
|
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
function discordReplyAttachment(attachment) {
|
|
210
|
+
if (!attachment || typeof attachment !== 'object') return null;
|
|
211
|
+
const mediaType = typeof attachment.content_type === 'string'
|
|
212
|
+
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
|
|
213
|
+
const kind = mediaType.startsWith('image/') ? 'image'
|
|
214
|
+
: mediaType.startsWith('audio/') ? 'audio'
|
|
215
|
+
: mediaType.startsWith('video/') ? 'video' : 'file';
|
|
216
|
+
const name = typeof attachment.filename === 'string' && attachment.filename
|
|
217
|
+
? attachment.filename : undefined;
|
|
218
|
+
return { kind, ...(name ? { name } : {}) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function discordReplySnapshot(message, fallbackMessageId) {
|
|
222
|
+
if (!message || typeof message !== 'object') return null;
|
|
223
|
+
const messageId = typeof message.id === 'string' && message.id
|
|
224
|
+
? message.id : fallbackMessageId;
|
|
225
|
+
const authorId = typeof message.author?.id === 'string' && message.author.id
|
|
226
|
+
? message.author.id : undefined;
|
|
227
|
+
const authorName = [message.member?.nick, message.author?.global_name, message.author?.username]
|
|
228
|
+
.find((value) => typeof value === 'string' && value.trim());
|
|
229
|
+
const attachments = Array.isArray(message.attachments)
|
|
230
|
+
? message.attachments.map(discordReplyAttachment).filter(Boolean)
|
|
231
|
+
: [];
|
|
232
|
+
if (Array.isArray(message.sticker_items)) {
|
|
233
|
+
attachments.push(...message.sticker_items.map((sticker) => ({
|
|
234
|
+
kind: 'image',
|
|
235
|
+
...(typeof sticker?.name === 'string' && sticker.name ? { name: sticker.name } : {}),
|
|
236
|
+
})));
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
...(messageId ? { messageId: String(messageId) } : {}),
|
|
240
|
+
...(authorId ? { authorId } : {}),
|
|
241
|
+
...(authorName ? { authorName } : {}),
|
|
242
|
+
content: typeof message.content === 'string' ? message.content : '',
|
|
243
|
+
attachments,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function discordReplyReference(message, loadReply) {
|
|
248
|
+
const channelId = String(message?.channel_id ?? '');
|
|
249
|
+
const referenceId = typeof message?.message_reference?.message_id === 'string'
|
|
250
|
+
&& message.message_reference.message_id
|
|
251
|
+
? message.message_reference.message_id : undefined;
|
|
252
|
+
const referenceChannelId = message?.message_reference?.channel_id;
|
|
253
|
+
if (referenceChannelId !== undefined && String(referenceChannelId) !== channelId) {
|
|
254
|
+
return {
|
|
255
|
+
...(referenceId ? { messageId: referenceId } : {}),
|
|
256
|
+
unavailableReason: 'not-found',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (Object.hasOwn(message ?? {}, 'referenced_message')) {
|
|
260
|
+
if (message.referenced_message === null) {
|
|
261
|
+
return {
|
|
262
|
+
...(referenceId ? { messageId: referenceId } : {}),
|
|
263
|
+
unavailableReason: 'deleted',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (message.referenced_message && typeof message.referenced_message === 'object') {
|
|
267
|
+
const snapshotId = typeof message.referenced_message.id === 'string'
|
|
268
|
+
&& message.referenced_message.id ? message.referenced_message.id : undefined;
|
|
269
|
+
if (String(message.referenced_message.channel_id ?? '') !== channelId
|
|
270
|
+
|| !snapshotId || (referenceId && snapshotId !== referenceId)) {
|
|
271
|
+
return {
|
|
272
|
+
...(referenceId ? { messageId: referenceId } : {}),
|
|
273
|
+
unavailableReason: 'not-found',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return discordReplySnapshot(message.referenced_message, referenceId) ?? undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!referenceId) return undefined;
|
|
280
|
+
if (typeof loadReply !== 'function') {
|
|
281
|
+
return { messageId: referenceId, unavailableReason: 'not-delivered' };
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
messageId: referenceId,
|
|
285
|
+
load: async ({ signal } = {}) => {
|
|
286
|
+
const referenced = await loadReply({ channelId, messageId: referenceId, signal });
|
|
287
|
+
if (!referenced || String(referenced.id ?? '') !== referenceId
|
|
288
|
+
|| String(referenced.channel_id ?? '') !== channelId) return null;
|
|
289
|
+
return discordReplySnapshot(referenced, referenceId);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function normalizeDiscordMessage(message, botId, {
|
|
295
|
+
fetchImpl = fetch,
|
|
296
|
+
loadReply,
|
|
297
|
+
} = {}) {
|
|
210
298
|
if (!message?.id || !message?.channel_id || !message?.author?.id
|
|
211
299
|
|| Number(message.type) === 21) return null;
|
|
212
300
|
const direct = !message.guild_id;
|
|
213
301
|
const addressed = direct
|
|
214
302
|
|| message.mentions?.some((mention) => String(mention?.id) === String(botId));
|
|
303
|
+
const replyTo = discordReplyReference(message, loadReply);
|
|
215
304
|
return {
|
|
216
305
|
messageId: String(message.id),
|
|
217
306
|
senderId: String(message.author.id),
|
|
@@ -232,6 +321,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|
|
232
321
|
files: Array.isArray(message.attachments)
|
|
233
322
|
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
|
|
234
323
|
: [],
|
|
324
|
+
...(replyTo ? { replyTo } : {}),
|
|
235
325
|
addressed,
|
|
236
326
|
replyTarget: {
|
|
237
327
|
channelId: String(message.channel_id),
|
|
@@ -252,7 +342,12 @@ export async function resolveDiscordMessageRoute(message, botId, {
|
|
|
252
342
|
signal,
|
|
253
343
|
onChannel,
|
|
254
344
|
} = {}) {
|
|
255
|
-
const normalized = normalizeDiscordMessage(message, botId, {
|
|
345
|
+
const normalized = normalizeDiscordMessage(message, botId, {
|
|
346
|
+
fetchImpl,
|
|
347
|
+
loadReply: typeof api?.getMessage === 'function'
|
|
348
|
+
? (options) => api.getMessage(options)
|
|
349
|
+
: undefined,
|
|
350
|
+
});
|
|
256
351
|
if (!normalized || normalized.senderIsBot) return normalized;
|
|
257
352
|
signal?.throwIfAborted();
|
|
258
353
|
if (normalized.kind === 'direct') {
|
|
@@ -11,8 +11,11 @@ import {
|
|
|
11
11
|
hasInboundImages,
|
|
12
12
|
imagePromptDiagnostic,
|
|
13
13
|
imagePromptUserMessage,
|
|
14
|
-
promptContentForMessage,
|
|
15
14
|
} from '../shared/image-prompt.mjs';
|
|
15
|
+
import {
|
|
16
|
+
hasReplyReference,
|
|
17
|
+
promptContentForInboundMessage,
|
|
18
|
+
} from '../shared/semantic/reply-reference.mjs';
|
|
16
19
|
import {
|
|
17
20
|
hasInboundFiles,
|
|
18
21
|
inboundFileUserMessage,
|
|
@@ -153,6 +156,7 @@ function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}
|
|
|
153
156
|
|
|
154
157
|
/** Canonical workspace/session help advertised by every bridge family. */
|
|
155
158
|
const WORKSPACE_HELP_LINES = [
|
|
159
|
+
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
156
160
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
157
161
|
'/workspacelist 列出工作区绝对路径',
|
|
158
162
|
'/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
@@ -625,7 +629,9 @@ export class FeishuHarnessBridge {
|
|
|
625
629
|
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
|
626
630
|
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
|
627
631
|
: this.#batchInputs.handle(key, batchText, {
|
|
628
|
-
plainText: event.message.message_type === 'text'
|
|
632
|
+
plainText: event.message.message_type === 'text'
|
|
633
|
+
&& Boolean(batchText)
|
|
634
|
+
&& !hasReplyReference(commandMessage),
|
|
629
635
|
});
|
|
630
636
|
if (result.handled) {
|
|
631
637
|
if (result.kind === 'submit') {
|
|
@@ -1031,11 +1037,12 @@ export class FeishuHarnessBridge {
|
|
|
1031
1037
|
const text = message.content;
|
|
1032
1038
|
const hasImages = hasInboundImages(message);
|
|
1033
1039
|
const hasFiles = hasInboundFiles(message);
|
|
1040
|
+
const hasReply = hasReplyReference(message);
|
|
1034
1041
|
// 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
|
|
1035
1042
|
// 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
|
|
1036
1043
|
// accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
|
|
1037
1044
|
const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
|
|
1038
|
-
if (!text && !hasImages && !hasFiles) {
|
|
1045
|
+
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
1039
1046
|
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
|
|
1040
1047
|
return;
|
|
1041
1048
|
}
|
|
@@ -3288,15 +3295,18 @@ export class FeishuHarnessBridge {
|
|
|
3288
3295
|
askCompleted = true;
|
|
3289
3296
|
onAskComplete?.();
|
|
3290
3297
|
};
|
|
3291
|
-
let content = hasInboundImages(message)
|
|
3292
|
-
? await
|
|
3298
|
+
let content = hasInboundImages(message) || hasReplyReference(message)
|
|
3299
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
3293
3300
|
: undefined;
|
|
3294
3301
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
3302
|
+
let contextEnhanced = false;
|
|
3295
3303
|
if (snapshot) {
|
|
3296
|
-
|
|
3304
|
+
const originalContent = content ?? text;
|
|
3305
|
+
content = enhanceContextContent(originalContent, snapshot, () => ({
|
|
3297
3306
|
channel: 'feishu',
|
|
3298
3307
|
senderId: senderOpenId(event),
|
|
3299
3308
|
}));
|
|
3309
|
+
contextEnhanced = content !== originalContent;
|
|
3300
3310
|
}
|
|
3301
3311
|
if (!this.#channel?.stream) {
|
|
3302
3312
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
@@ -3305,6 +3315,7 @@ export class FeishuHarnessBridge {
|
|
|
3305
3315
|
key,
|
|
3306
3316
|
text,
|
|
3307
3317
|
content,
|
|
3318
|
+
contextEnhanced,
|
|
3308
3319
|
createOptions: { signal: this.#signal },
|
|
3309
3320
|
existsOptions: { signal: this.#signal },
|
|
3310
3321
|
askOptions: this.#interactionAskOptions(event, key, message.files),
|
|
@@ -3351,8 +3362,18 @@ export class FeishuHarnessBridge {
|
|
|
3351
3362
|
stream = await this.#channel.stream(chatId, {
|
|
3352
3363
|
markdown: async (controller) => {
|
|
3353
3364
|
promptStarted = true;
|
|
3365
|
+
const baseAskOptions = this.#interactionAskOptions(event, key, message.files);
|
|
3354
3366
|
const askOptions = {
|
|
3355
|
-
...
|
|
3367
|
+
...baseAskOptions,
|
|
3368
|
+
// issue #86:独立交互消息(提问/审批)会落在占位卡下方,呈现前
|
|
3369
|
+
// 先换卡,让最终答案落在交互消息之后的新流式卡上。
|
|
3370
|
+
onInteraction: async (interaction) => {
|
|
3371
|
+
if ((interaction?.kind === 'question' || interaction?.kind === 'approval')
|
|
3372
|
+
&& typeof controller?.rotate === 'function') {
|
|
3373
|
+
await controller.rotate();
|
|
3374
|
+
}
|
|
3375
|
+
await baseAskOptions.onInteraction(interaction);
|
|
3376
|
+
},
|
|
3356
3377
|
onUpdate: async (update) => {
|
|
3357
3378
|
await controller.setContent(this.#progressText(update));
|
|
3358
3379
|
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
|
@@ -3364,6 +3385,7 @@ export class FeishuHarnessBridge {
|
|
|
3364
3385
|
key,
|
|
3365
3386
|
text,
|
|
3366
3387
|
content,
|
|
3388
|
+
contextEnhanced,
|
|
3367
3389
|
createOptions: { signal: this.#signal },
|
|
3368
3390
|
existsOptions: { signal: this.#signal },
|
|
3369
3391
|
askOptions,
|
|
@@ -3427,6 +3449,7 @@ export class FeishuHarnessBridge {
|
|
|
3427
3449
|
key,
|
|
3428
3450
|
text,
|
|
3429
3451
|
content,
|
|
3452
|
+
contextEnhanced,
|
|
3430
3453
|
createOptions: { signal: this.#signal },
|
|
3431
3454
|
existsOptions: { signal: this.#signal },
|
|
3432
3455
|
askOptions: this.#interactionAskOptions(event, key, message.files),
|
|
@@ -490,7 +490,7 @@ export function menuHelpText() {
|
|
|
490
490
|
'/sessionlist 或 /sessions 列出工作区会话',
|
|
491
491
|
'/session ID 绑定已有会话',
|
|
492
492
|
'/workspacelist 列出工作区',
|
|
493
|
-
'/workspace
|
|
493
|
+
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
494
494
|
'/new 开启全新会话',
|
|
495
495
|
'',
|
|
496
496
|
'📊 状态 / 压缩',
|
|
@@ -554,7 +554,7 @@ const HELP_TEXT_COMMANDS = [
|
|
|
554
554
|
'`/new` — 开启全新会话',
|
|
555
555
|
'`/session ID` — 绑定已有会话',
|
|
556
556
|
'`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话',
|
|
557
|
-
'`/workspace
|
|
557
|
+
'`/workspace 工作区序号或绝对路径` — 切换工作区',
|
|
558
558
|
'`/workspacelist` — 列出工作区',
|
|
559
559
|
'`/status` — 查看连接状态',
|
|
560
560
|
'`/compact` — 压缩上下文',
|
|
@@ -205,15 +205,45 @@ export class VerifiedFeishuChannel {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
const cards = [];
|
|
208
|
+
let activeCard = null;
|
|
209
|
+
let rotating = false;
|
|
208
210
|
try {
|
|
209
|
-
|
|
210
|
-
cards.push(
|
|
211
|
+
activeCard = await this.#createStreamCard(chatId, options.replyTo);
|
|
212
|
+
cards.push(activeCard);
|
|
211
213
|
let lastContent = this.#initialText;
|
|
214
|
+
// issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
|
|
215
|
+
// 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态;
|
|
216
|
+
// 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于
|
|
217
|
+
// 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
|
|
218
|
+
const ensureActiveCard = async () => {
|
|
219
|
+
if (!rotating) return activeCard;
|
|
220
|
+
activeCard = await this.#createStreamCard(chatId, options.replyTo);
|
|
221
|
+
cards.push(activeCard);
|
|
222
|
+
rotating = false;
|
|
223
|
+
return activeCard;
|
|
224
|
+
};
|
|
212
225
|
const controller = {
|
|
213
|
-
messageId
|
|
226
|
+
get messageId() {
|
|
227
|
+
return activeCard.messageId;
|
|
228
|
+
},
|
|
229
|
+
rotate: async () => {
|
|
230
|
+
if (rotating) return;
|
|
231
|
+
rotating = true;
|
|
232
|
+
try {
|
|
233
|
+
await this.#updateStreamCard(
|
|
234
|
+
activeCard,
|
|
235
|
+
`${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`,
|
|
236
|
+
);
|
|
237
|
+
await this.#finishStreamCard(activeCard);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。
|
|
240
|
+
console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message);
|
|
241
|
+
}
|
|
242
|
+
},
|
|
214
243
|
setContent: async (content) => {
|
|
215
244
|
const next = String(content ?? '') || '…';
|
|
216
|
-
await
|
|
245
|
+
const card = await ensureActiveCard();
|
|
246
|
+
await this.#updateStreamCard(card, streamPreview(next));
|
|
217
247
|
// Updates are replaceable snapshots, including progress/tool text.
|
|
218
248
|
// Retain the full latest snapshot even when its preview is unchanged.
|
|
219
249
|
lastContent = next;
|
|
@@ -224,14 +254,14 @@ export class VerifiedFeishuChannel {
|
|
|
224
254
|
const chunks = splitStreamContent(lastContent);
|
|
225
255
|
for (const [index, chunk] of chunks.entries()) {
|
|
226
256
|
const card = index === 0
|
|
227
|
-
?
|
|
257
|
+
? await ensureActiveCard()
|
|
228
258
|
: await this.#createStreamCard(chatId, options.replyTo);
|
|
229
259
|
if (index > 0) cards.push(card);
|
|
230
260
|
await this.#updateStreamCard(card, chunk);
|
|
231
261
|
await this.#finishStreamCard(card);
|
|
232
262
|
}
|
|
233
263
|
return {
|
|
234
|
-
messageId:
|
|
264
|
+
messageId: cards[0].messageId,
|
|
235
265
|
providerMessageIds: cards.map((card) => card.messageId),
|
|
236
266
|
};
|
|
237
267
|
} catch (error) {
|
|
@@ -2,8 +2,14 @@ import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
|
2
2
|
import { t } from '../shared/i18n.mjs';
|
|
3
3
|
|
|
4
4
|
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|
5
|
+
const FEISHU_CARD_MESSAGE_CONTENT_TYPE = 'raw_card_content';
|
|
5
6
|
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
|
6
7
|
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
|
8
|
+
const FEISHU_CARD_TEXT_MAX_DEPTH = 12;
|
|
9
|
+
const FEISHU_CARD_TEXT_MAX_NODES = 1_000;
|
|
10
|
+
const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
|
|
11
|
+
'请升级至最新版本客户端,以查看内容',
|
|
12
|
+
]);
|
|
7
13
|
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
8
14
|
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
|
9
15
|
|
|
@@ -56,6 +62,124 @@ function nonEmptyString(value) {
|
|
|
56
62
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
function objectRecord(value) {
|
|
66
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonRecord(value) {
|
|
70
|
+
if (objectRecord(value)) return value;
|
|
71
|
+
if (typeof value !== 'string') return null;
|
|
72
|
+
try {
|
|
73
|
+
return objectRecord(JSON.parse(value));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function interactiveCardRoot(parsed) {
|
|
80
|
+
const root = objectRecord(parsed);
|
|
81
|
+
if (!root) return null;
|
|
82
|
+
// raw_card_content wraps CardKit entities in json_card. Keep direct Card
|
|
83
|
+
// 1.0/2.0 payloads readable as well for historical messages and fixtures.
|
|
84
|
+
return jsonRecord(root.json_card) ?? jsonRecord(root.card) ?? root;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cardProperty(value) {
|
|
88
|
+
const record = objectRecord(value);
|
|
89
|
+
return objectRecord(record?.property) ?? record;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cardTextContent(property) {
|
|
93
|
+
const i18n = objectRecord(property?.i18nContent);
|
|
94
|
+
const content = nonEmptyString(i18n?.zh_cn)
|
|
95
|
+
?? nonEmptyString(i18n?.en_us)
|
|
96
|
+
?? nonEmptyString(i18n?.ja_jp)
|
|
97
|
+
?? nonEmptyString(property?.content)
|
|
98
|
+
?? nonEmptyString(property?.text);
|
|
99
|
+
return content && !FEISHU_CARD_UNAVAILABLE_TEXTS.has(content) ? content : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cardElementText(
|
|
103
|
+
element,
|
|
104
|
+
{ depth = 0, inline = false, budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES } } = {},
|
|
105
|
+
) {
|
|
106
|
+
if (depth > FEISHU_CARD_TEXT_MAX_DEPTH || budget.remaining <= 0) return '';
|
|
107
|
+
budget.remaining -= 1;
|
|
108
|
+
if (Array.isArray(element)) {
|
|
109
|
+
return element
|
|
110
|
+
.map((part) => cardElementText(part, {
|
|
111
|
+
depth: depth + 1,
|
|
112
|
+
inline: Array.isArray(part),
|
|
113
|
+
budget,
|
|
114
|
+
}))
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(inline ? ' ' : '\n');
|
|
117
|
+
}
|
|
118
|
+
const value = objectRecord(element);
|
|
119
|
+
if (!value) return '';
|
|
120
|
+
const property = cardProperty(value);
|
|
121
|
+
if (!property) return '';
|
|
122
|
+
const tag = String(value.tag ?? '').toLowerCase();
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
tag === 'markdown'
|
|
126
|
+
|| tag === 'markdown_v1'
|
|
127
|
+
|| tag === 'lark_md'
|
|
128
|
+
|| tag === 'plain_text'
|
|
129
|
+
|| tag === 'text'
|
|
130
|
+
) {
|
|
131
|
+
const content = cardTextContent(property);
|
|
132
|
+
if (content) return content;
|
|
133
|
+
const nested = cardElementText(property.elements, {
|
|
134
|
+
depth: depth + 1,
|
|
135
|
+
inline: true,
|
|
136
|
+
budget,
|
|
137
|
+
});
|
|
138
|
+
if (nested || tag !== 'markdown_v1') return nested;
|
|
139
|
+
return cardElementText(value.fallback ?? property.fallback, {
|
|
140
|
+
depth: depth + 1,
|
|
141
|
+
inline: true,
|
|
142
|
+
budget,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (tag === 'a' || tag === 'link' || tag === 'button') {
|
|
146
|
+
if (typeof property.text === 'string') return nonEmptyString(property.text) ?? '';
|
|
147
|
+
return cardElementText(property.text, { depth: depth + 1, inline: true, budget });
|
|
148
|
+
}
|
|
149
|
+
if (tag === 'div') {
|
|
150
|
+
return [
|
|
151
|
+
cardElementText(property.text, { depth: depth + 1, budget }),
|
|
152
|
+
cardElementText(property.fields, { depth: depth + 1, budget }),
|
|
153
|
+
].filter(Boolean).join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Traverse visible layout containers only. Deliberately ignore callback
|
|
157
|
+
// values, form state, URLs, ids, template variables and other hidden data.
|
|
158
|
+
return ['elements', 'columns', 'fields', 'children', 'actions']
|
|
159
|
+
.map((key) => cardElementText(property[key], { depth: depth + 1, budget }))
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function interactiveCardText(parsed) {
|
|
165
|
+
const card = interactiveCardRoot(parsed);
|
|
166
|
+
if (!card) return '';
|
|
167
|
+
const budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES };
|
|
168
|
+
const header = cardProperty(card.header);
|
|
169
|
+
const title = [
|
|
170
|
+
cardElementText(header?.title, { inline: true, budget }),
|
|
171
|
+
cardElementText(header?.subtitle, { inline: true, budget }),
|
|
172
|
+
].filter(Boolean).join('\n') || nonEmptyString(card.title) || '';
|
|
173
|
+
const body = cardProperty(card.body);
|
|
174
|
+
const elements = Array.isArray(body?.elements)
|
|
175
|
+
? body.elements
|
|
176
|
+
: Array.isArray(card.elements)
|
|
177
|
+
? card.elements
|
|
178
|
+
: null;
|
|
179
|
+
const content = cardElementText(elements, { budget });
|
|
180
|
+
return [title, content].filter(Boolean).join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
59
183
|
function postContent(event, parsed = parsedMessageContent(event)) {
|
|
60
184
|
if (event?.message?.message_type !== 'post') return null;
|
|
61
185
|
if (!parsed) return null;
|
|
@@ -283,6 +407,109 @@ function feishuFileSource(event, client, file) {
|
|
|
283
407
|
};
|
|
284
408
|
}
|
|
285
409
|
|
|
410
|
+
function feishuReplyTargetId(event) {
|
|
411
|
+
const parentId = nonEmptyString(event?.message?.parent_id);
|
|
412
|
+
if (parentId) return parentId;
|
|
413
|
+
const rootId = nonEmptyString(event?.message?.root_id);
|
|
414
|
+
const messageId = nonEmptyString(event?.message?.message_id);
|
|
415
|
+
return rootId && rootId !== messageId ? rootId : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function feishuReplyAttachments(messageType, parsed, post) {
|
|
419
|
+
if (messageType === 'post') {
|
|
420
|
+
return (post?.imageKeys ?? []).map(() => ({ kind: 'image' }));
|
|
421
|
+
}
|
|
422
|
+
if (messageType === 'image') return [{ kind: 'image' }];
|
|
423
|
+
if (messageType === 'file') {
|
|
424
|
+
return [{
|
|
425
|
+
kind: 'file',
|
|
426
|
+
...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
|
|
427
|
+
}];
|
|
428
|
+
}
|
|
429
|
+
if (messageType === 'audio') return [{ kind: 'audio' }];
|
|
430
|
+
if (messageType === 'media') {
|
|
431
|
+
return [{
|
|
432
|
+
kind: 'video',
|
|
433
|
+
...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
|
|
434
|
+
}];
|
|
435
|
+
}
|
|
436
|
+
if (messageType === 'sticker') return [{ kind: 'other' }];
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function feishuReplyReference(event, client) {
|
|
441
|
+
const messageId = feishuReplyTargetId(event);
|
|
442
|
+
if (!messageId) return null;
|
|
443
|
+
const chatId = nonEmptyString(event?.message?.chat_id);
|
|
444
|
+
return {
|
|
445
|
+
messageId,
|
|
446
|
+
async load({ signal } = {}) {
|
|
447
|
+
signal?.throwIfAborted();
|
|
448
|
+
let response;
|
|
449
|
+
try {
|
|
450
|
+
response = await client?.im?.v1?.message?.get?.({
|
|
451
|
+
path: { message_id: messageId },
|
|
452
|
+
params: {
|
|
453
|
+
with_sender_name: true,
|
|
454
|
+
card_msg_content_type: FEISHU_CARD_MESSAGE_CONTENT_TYPE,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
signal?.throwIfAborted();
|
|
459
|
+
if (await feishuProviderCode(error, signal) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
460
|
+
return { messageId, unavailableReason: 'permission-denied' };
|
|
461
|
+
}
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
signal?.throwIfAborted();
|
|
465
|
+
if (providerCode(response) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
466
|
+
return { messageId, unavailableReason: 'permission-denied' };
|
|
467
|
+
}
|
|
468
|
+
if (providerCode(response) !== null && providerCode(response) !== 0) {
|
|
469
|
+
return { messageId, unavailableReason: 'not-delivered' };
|
|
470
|
+
}
|
|
471
|
+
const item = response?.data?.items?.find?.(
|
|
472
|
+
(candidate) => nonEmptyString(candidate?.message_id) === messageId,
|
|
473
|
+
);
|
|
474
|
+
if (!item) return { messageId, unavailableReason: 'not-found' };
|
|
475
|
+
if (item.deleted) return { messageId, unavailableReason: 'deleted' };
|
|
476
|
+
const itemChatId = nonEmptyString(item.chat_id);
|
|
477
|
+
if (!chatId || !itemChatId || itemChatId !== chatId) {
|
|
478
|
+
return { messageId, unavailableReason: 'not-found' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const messageType = nonEmptyString(item.msg_type) ?? '';
|
|
482
|
+
const quotedEvent = {
|
|
483
|
+
message: {
|
|
484
|
+
message_id: messageId,
|
|
485
|
+
message_type: messageType,
|
|
486
|
+
content: item.body?.content,
|
|
487
|
+
mentions: item.mentions ?? [],
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
const parsed = parsedMessageContent(quotedEvent);
|
|
491
|
+
const post = postContent(quotedEvent, parsed);
|
|
492
|
+
const quoted = extractInboundMessage(quotedEvent, client);
|
|
493
|
+
const content = messageType === 'interactive'
|
|
494
|
+
? interactiveCardText(parsed)
|
|
495
|
+
: quoted.content;
|
|
496
|
+
const attachments = feishuReplyAttachments(messageType, parsed, post);
|
|
497
|
+
return {
|
|
498
|
+
messageId,
|
|
499
|
+
...(nonEmptyString(item.sender?.id) ? { authorId: nonEmptyString(item.sender.id) } : {}),
|
|
500
|
+
...(nonEmptyString(item.sender?.sender_name)
|
|
501
|
+
? { authorName: nonEmptyString(item.sender.sender_name) }
|
|
502
|
+
: {}),
|
|
503
|
+
...(content ? { content } : {}),
|
|
504
|
+
attachments,
|
|
505
|
+
...(messageType === 'interactive' && !content && attachments.length === 0
|
|
506
|
+
? { unavailableReason: 'unsupported' }
|
|
507
|
+
: {}),
|
|
508
|
+
};
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
286
513
|
export function extractInboundMessage(event, client) {
|
|
287
514
|
const messageType = event?.message?.message_type;
|
|
288
515
|
const parsed = parsedMessageContent(event);
|
|
@@ -292,10 +519,12 @@ export function extractInboundMessage(event, client) {
|
|
|
292
519
|
: null;
|
|
293
520
|
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
|
294
521
|
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
|
|
522
|
+
const replyTo = feishuReplyReference(event, client);
|
|
295
523
|
return {
|
|
296
524
|
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
|
297
525
|
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
|
298
526
|
files: file ? [file] : [],
|
|
527
|
+
...(replyTo ? { replyTo } : {}),
|
|
299
528
|
};
|
|
300
529
|
}
|
|
301
530
|
|