@m1heng-clawd/feishu 0.1.13 → 0.1.15
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.md +82 -2
- package/index.ts +7 -2
- package/package.json +5 -4
- package/skills/feishu-chat/SKILL.md +139 -0
- package/skills/feishu-urgent/SKILL.md +107 -0
- package/src/bot.ts +569 -59
- package/src/channel.ts +2 -0
- package/src/chat-tools/actions.ts +433 -0
- package/src/chat-tools/common.ts +13 -0
- package/src/chat-tools/index.ts +2 -0
- package/src/chat-tools/register.ts +72 -0
- package/src/chat-tools/schemas.ts +66 -0
- package/src/client.ts +13 -0
- package/src/config-schema.ts +6 -0
- package/src/drive-tools/actions.ts +57 -21
- package/src/drive-tools/schemas.ts +2 -2
- package/src/media-duration.ts +190 -0
- package/src/media.ts +60 -5
- package/src/mention.ts +1 -1
- package/src/monitor.ts +32 -8
- package/src/policy.ts +7 -3
- package/src/reply-dispatcher.ts +97 -13
- package/src/send.ts +360 -42
- package/src/streaming-card.ts +32 -4
- package/src/tools-config.ts +9 -2
- package/src/types.ts +8 -1
- package/src/typing.ts +115 -5
- package/src/urgent-tools/actions.ts +84 -0
- package/src/urgent-tools/index.ts +3 -0
- package/src/urgent-tools/register.ts +64 -0
- package/src/urgent-tools/schemas.ts +25 -0
package/src/reply-dispatcher.ts
CHANGED
|
@@ -16,6 +16,24 @@ import { FeishuStreamingSession } from "./streaming-card.js";
|
|
|
16
16
|
import { resolveReceiveIdType } from "./targets.js";
|
|
17
17
|
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
|
18
18
|
|
|
19
|
+
/** Maximum age (ms) for a message to receive a typing indicator reaction.
|
|
20
|
+
* Messages older than this are likely replays after context compaction (#30418). */
|
|
21
|
+
const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000;
|
|
22
|
+
|
|
23
|
+
/** Minimum value to treat a timestamp as epoch-milliseconds vs epoch-seconds. */
|
|
24
|
+
const MS_EPOCH_MIN = 1_000_000_000_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize a timestamp to epoch-milliseconds.
|
|
28
|
+
* Some Feishu payloads use epoch-seconds; values below 1e12 are treated as such.
|
|
29
|
+
*/
|
|
30
|
+
function normalizeEpochMs(timestamp: number | undefined): number | undefined {
|
|
31
|
+
if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
/** Detect if text contains markdown elements that benefit from card rendering */
|
|
20
38
|
function shouldUseCard(text: string): boolean {
|
|
21
39
|
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
|
@@ -29,11 +47,14 @@ export type CreateFeishuReplyDispatcherParams = {
|
|
|
29
47
|
replyToMessageId?: string;
|
|
30
48
|
mentionTargets?: MentionTarget[];
|
|
31
49
|
accountId?: string;
|
|
50
|
+
/** Epoch ms when the inbound message was created. Used to suppress typing
|
|
51
|
+
* indicators on old/replayed messages after context compaction (#30418). */
|
|
52
|
+
messageCreateTimeMs?: number;
|
|
32
53
|
};
|
|
33
54
|
|
|
34
55
|
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
|
35
56
|
const core = getFeishuRuntime();
|
|
36
|
-
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
|
|
57
|
+
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId, messageCreateTimeMs } = params;
|
|
37
58
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
38
59
|
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
|
39
60
|
|
|
@@ -43,6 +64,21 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
43
64
|
if (!replyToMessageId) {
|
|
44
65
|
return;
|
|
45
66
|
}
|
|
67
|
+
// Skip typing indicator for old messages — likely replays after context
|
|
68
|
+
// compaction that would flood users with stale notifications (#30418).
|
|
69
|
+
const normalizedCreateTime = normalizeEpochMs(messageCreateTimeMs);
|
|
70
|
+
if (
|
|
71
|
+
normalizedCreateTime !== undefined &&
|
|
72
|
+
Date.now() - normalizedCreateTime > TYPING_INDICATOR_MAX_AGE_MS
|
|
73
|
+
) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Feishu reactions persist until explicitly removed, so skip keepalive
|
|
77
|
+
// re-adds when a reaction already exists. Re-adding the same emoji
|
|
78
|
+
// triggers a new push notification for every call (#28660).
|
|
79
|
+
if (typingState?.reactionId) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
46
82
|
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
|
|
47
83
|
},
|
|
48
84
|
stop: async () => {
|
|
@@ -86,6 +122,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
86
122
|
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
|
87
123
|
let streamingStartPromise: Promise<void> | null = null;
|
|
88
124
|
|
|
125
|
+
const mergeStreamingText = (nextText: string) => {
|
|
126
|
+
if (!streamText) {
|
|
127
|
+
streamText = nextText;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (nextText.startsWith(streamText)) {
|
|
131
|
+
// Handle cumulative partial payloads where nextText already includes prior text.
|
|
132
|
+
streamText = nextText;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (streamText.endsWith(nextText)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
streamText += nextText;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const queueStreamingUpdate = (
|
|
142
|
+
nextText: string,
|
|
143
|
+
options?: { dedupeWithLastPartial?: boolean },
|
|
144
|
+
) => {
|
|
145
|
+
if (!nextText) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (options?.dedupeWithLastPartial && nextText === lastPartial) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (options?.dedupeWithLastPartial) {
|
|
152
|
+
lastPartial = nextText;
|
|
153
|
+
}
|
|
154
|
+
mergeStreamingText(nextText);
|
|
155
|
+
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
|
156
|
+
if (streamingStartPromise) {
|
|
157
|
+
await streamingStartPromise;
|
|
158
|
+
}
|
|
159
|
+
if (streaming?.isActive()) {
|
|
160
|
+
await streaming.update(streamText);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
89
165
|
const startStreaming = () => {
|
|
90
166
|
if (!streamingEnabled || streamingStartPromise || streaming) {
|
|
91
167
|
return;
|
|
@@ -148,7 +224,19 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
148
224
|
|
|
149
225
|
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
150
226
|
|
|
151
|
-
if (
|
|
227
|
+
if (info?.kind === "block") {
|
|
228
|
+
// Drop internal block chunks unless we can safely consume them as
|
|
229
|
+
// streaming-card fallback content (#31723).
|
|
230
|
+
if (!(streamingEnabled && useCard)) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
startStreaming();
|
|
234
|
+
if (streamingStartPromise) {
|
|
235
|
+
await streamingStartPromise;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (info?.kind === "final" && streamingEnabled && useCard) {
|
|
152
240
|
startStreaming();
|
|
153
241
|
if (streamingStartPromise) {
|
|
154
242
|
await streamingStartPromise;
|
|
@@ -156,6 +244,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
156
244
|
}
|
|
157
245
|
|
|
158
246
|
if (streaming?.isActive()) {
|
|
247
|
+
if (info?.kind === "block") {
|
|
248
|
+
// Some runtimes emit block payloads without onPartial/final callbacks.
|
|
249
|
+
// Mirror block text into streamText so onIdle close still sends content.
|
|
250
|
+
queueStreamingUpdate(text);
|
|
251
|
+
}
|
|
159
252
|
if (info?.kind === "final") {
|
|
160
253
|
streamText = text;
|
|
161
254
|
await closeStreaming();
|
|
@@ -219,19 +312,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
219
312
|
onPartialReply: streamingEnabled
|
|
220
313
|
? (payload: ReplyPayload) => {
|
|
221
314
|
const partialText = normalizeFeishuMarkdownLinks(payload.text ?? "");
|
|
222
|
-
if (!partialText
|
|
315
|
+
if (!partialText) {
|
|
223
316
|
return;
|
|
224
317
|
}
|
|
225
|
-
|
|
226
|
-
streamText = partialText;
|
|
227
|
-
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
|
228
|
-
if (streamingStartPromise) {
|
|
229
|
-
await streamingStartPromise;
|
|
230
|
-
}
|
|
231
|
-
if (streaming?.isActive()) {
|
|
232
|
-
await streaming.update(streamText);
|
|
233
|
-
}
|
|
234
|
-
});
|
|
318
|
+
queueStreamingUpdate(partialText, { dedupeWithLastPartial: true });
|
|
235
319
|
}
|
|
236
320
|
: undefined,
|
|
237
321
|
},
|
package/src/send.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createFeishuClient } from "./client.js";
|
|
|
6
6
|
import { normalizeFeishuMarkdownLinks } from "./text/markdown-links.js";
|
|
7
7
|
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
|
8
8
|
import { getFeishuRuntime } from "./runtime.js";
|
|
9
|
-
import { resolveFeishuAccount } from "./accounts.js";
|
|
9
|
+
import { listFeishuAccountIds, resolveFeishuAccount } from "./accounts.js";
|
|
10
10
|
|
|
11
11
|
export type FeishuMessageInfo = {
|
|
12
12
|
messageId: string;
|
|
@@ -18,6 +18,313 @@ export type FeishuMessageInfo = {
|
|
|
18
18
|
createTime?: number;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
const MERGE_FORWARD_NAME_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
22
|
+
const mergeForwardSenderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
23
|
+
const mergeForwardBotNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
24
|
+
const mergeForwardAppNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
25
|
+
|
|
26
|
+
type MergeForwardMention = {
|
|
27
|
+
key?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
id_type?: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function escapeRegExp(raw: string): string {
|
|
34
|
+
return raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildMentionDisplayName(mention: MergeForwardMention): string | undefined {
|
|
38
|
+
const mentionName = mention.name?.trim();
|
|
39
|
+
if (mentionName) return mentionName;
|
|
40
|
+
const mentionId = mention.id?.trim();
|
|
41
|
+
if (!mentionId) return undefined;
|
|
42
|
+
const mentionIdType = mention.id_type?.trim();
|
|
43
|
+
return mentionIdType ? `${mentionIdType}:${mentionId}` : mentionId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function replaceFeishuMentionPlaceholders(raw: string, mentions?: MergeForwardMention[]): string {
|
|
47
|
+
if (!raw) return raw;
|
|
48
|
+
let text = raw;
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(mentions)) {
|
|
51
|
+
for (const mention of mentions) {
|
|
52
|
+
const key = mention.key?.trim();
|
|
53
|
+
if (!key) continue;
|
|
54
|
+
const displayName = buildMentionDisplayName(mention);
|
|
55
|
+
if (!displayName) continue;
|
|
56
|
+
text = text.replace(new RegExp(escapeRegExp(key), "g"), `@${displayName}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Feishu text payloads may keep unresolved placeholders like "@_user_1".
|
|
61
|
+
return text.replace(/@_user_\d+\b/g, "@mentioned");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveCurrentBotName(params: {
|
|
65
|
+
client: any;
|
|
66
|
+
appId: string;
|
|
67
|
+
}): Promise<string | undefined> {
|
|
68
|
+
const cacheKey = `bot:${params.appId}`;
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const cached = mergeForwardBotNameCache.get(cacheKey);
|
|
71
|
+
if (cached && cached.expireAt > now) {
|
|
72
|
+
return cached.name;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof params.client?.request !== "function") return undefined;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const response = await params.client.request({
|
|
79
|
+
method: "GET",
|
|
80
|
+
url: "/open-apis/bot/v3/info",
|
|
81
|
+
data: {},
|
|
82
|
+
});
|
|
83
|
+
const botName = response?.data?.bot?.app_name;
|
|
84
|
+
if (typeof botName === "string" && botName.trim()) {
|
|
85
|
+
const normalized = botName.trim();
|
|
86
|
+
mergeForwardBotNameCache.set(cacheKey, {
|
|
87
|
+
name: normalized,
|
|
88
|
+
expireAt: now + MERGE_FORWARD_NAME_CACHE_TTL_MS,
|
|
89
|
+
});
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function resolveAppNameById(params: {
|
|
99
|
+
client: any;
|
|
100
|
+
accountId: string;
|
|
101
|
+
appId: string;
|
|
102
|
+
}): Promise<string | undefined> {
|
|
103
|
+
const appId = params.appId.trim();
|
|
104
|
+
if (!appId) return undefined;
|
|
105
|
+
|
|
106
|
+
const cacheKey = `${params.accountId}:${appId}`;
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const cached = mergeForwardAppNameCache.get(cacheKey);
|
|
109
|
+
if (cached && cached.expireAt > now) {
|
|
110
|
+
return cached.name;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof params.client?.request !== "function") return undefined;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const response = await params.client.request({
|
|
117
|
+
method: "GET",
|
|
118
|
+
url: `/open-apis/application/v6/applications/${encodeURIComponent(appId)}`,
|
|
119
|
+
params: {
|
|
120
|
+
lang: "en_us",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const appName = response?.data?.app?.app_name;
|
|
124
|
+
if (typeof appName === "string" && appName.trim()) {
|
|
125
|
+
const normalized = appName.trim();
|
|
126
|
+
mergeForwardAppNameCache.set(cacheKey, {
|
|
127
|
+
name: normalized,
|
|
128
|
+
expireAt: now + MERGE_FORWARD_NAME_CACHE_TTL_MS,
|
|
129
|
+
});
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolves a configured account's display name and accountId key by appId.
|
|
140
|
+
* A single pass over the account list covers both the explicit-name lookup
|
|
141
|
+
* and the accountId fallback label, avoiding duplicate iteration.
|
|
142
|
+
*/
|
|
143
|
+
function resolveConfiguredAppLabel(params: {
|
|
144
|
+
cfg: ClawdbotConfig;
|
|
145
|
+
appId: string;
|
|
146
|
+
}): { displayName: string | undefined; accountId: string | undefined } {
|
|
147
|
+
const targetAppId = params.appId.trim();
|
|
148
|
+
if (!targetAppId) return { displayName: undefined, accountId: undefined };
|
|
149
|
+
|
|
150
|
+
for (const accountId of listFeishuAccountIds(params.cfg)) {
|
|
151
|
+
const resolved = resolveFeishuAccount({ cfg: params.cfg, accountId });
|
|
152
|
+
if (!resolved.configured) continue;
|
|
153
|
+
if (resolved.appId !== targetAppId) continue;
|
|
154
|
+
return { displayName: resolved.name?.trim() || undefined, accountId };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { displayName: undefined, accountId: undefined };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function replaceConfiguredAppIdTokens(params: {
|
|
161
|
+
content: string;
|
|
162
|
+
cfg: ClawdbotConfig;
|
|
163
|
+
}): string {
|
|
164
|
+
if (!params.content) return params.content;
|
|
165
|
+
return params.content.replace(/\bapp_id:(cli_[a-z0-9]+)\b/g, (raw, appId: string) => {
|
|
166
|
+
const { displayName, accountId } = resolveConfiguredAppLabel({ cfg: params.cfg, appId });
|
|
167
|
+
return displayName ?? accountId ?? raw;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractPlainTextFromMessageBody(
|
|
172
|
+
rawContent: string,
|
|
173
|
+
msgType?: string,
|
|
174
|
+
mentions?: MergeForwardMention[],
|
|
175
|
+
): string {
|
|
176
|
+
let content = rawContent;
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(rawContent);
|
|
179
|
+
if (msgType === "text" && parsed.text) {
|
|
180
|
+
return replaceFeishuMentionPlaceholders(parsed.text, mentions);
|
|
181
|
+
}
|
|
182
|
+
if (typeof parsed.text === "string" && parsed.text.trim()) {
|
|
183
|
+
return replaceFeishuMentionPlaceholders(parsed.text, mentions);
|
|
184
|
+
}
|
|
185
|
+
if (typeof parsed.title === "string" && parsed.title.trim()) {
|
|
186
|
+
return parsed.title;
|
|
187
|
+
}
|
|
188
|
+
if (parsed.content || parsed.elements) {
|
|
189
|
+
// Extract plain text from rich text (post) or interactive (card) format.
|
|
190
|
+
// Both use nested arrays: Array<Array<{tag, text?, href?, ...}>>
|
|
191
|
+
const blocks = parsed.content ?? parsed.elements ?? [];
|
|
192
|
+
const lines: string[] = [];
|
|
193
|
+
for (const paragraph of blocks) {
|
|
194
|
+
if (!Array.isArray(paragraph)) continue;
|
|
195
|
+
const line = paragraph
|
|
196
|
+
.map((node: { tag?: string; text?: string; href?: string }) => {
|
|
197
|
+
if (node.tag === "text") return node.text ?? "";
|
|
198
|
+
if (node.tag === "a") return node.text ?? node.href ?? "";
|
|
199
|
+
if (node.tag === "at") return "";
|
|
200
|
+
if (node.tag === "img") return "[Image]";
|
|
201
|
+
return node.text ?? "";
|
|
202
|
+
})
|
|
203
|
+
.join("");
|
|
204
|
+
if (line.trim()) lines.push(line);
|
|
205
|
+
}
|
|
206
|
+
const extracted = (parsed.title ? parsed.title + "\n" : "") + lines.join("\n");
|
|
207
|
+
// Filter out Feishu's degraded card placeholder text
|
|
208
|
+
if (extracted.trim() && !extracted.includes("请升级至最新版本客户端")) {
|
|
209
|
+
content = extracted;
|
|
210
|
+
} else if (extracted.includes("请升级至最新版本客户端")) {
|
|
211
|
+
content = "[Card message]";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Keep raw content if parsing fails
|
|
216
|
+
}
|
|
217
|
+
return replaceFeishuMentionPlaceholders(content, mentions);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractSenderLabelFromMessageBody(rawContent: string): string | undefined {
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(rawContent);
|
|
223
|
+
const senderName = parsed?.sender_name || parsed?.sender?.name;
|
|
224
|
+
if (typeof senderName === "string" && senderName.trim()) {
|
|
225
|
+
return senderName.trim();
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
} catch {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildMergeForwardSenderLabel(params: {
|
|
234
|
+
cfg: ClawdbotConfig;
|
|
235
|
+
rawContent: string;
|
|
236
|
+
client: any;
|
|
237
|
+
accountId: string;
|
|
238
|
+
sender?: {
|
|
239
|
+
id?: string;
|
|
240
|
+
id_type?: string;
|
|
241
|
+
sender_type?: string;
|
|
242
|
+
};
|
|
243
|
+
}): Promise<string | undefined> {
|
|
244
|
+
const senderName = extractSenderLabelFromMessageBody(params.rawContent);
|
|
245
|
+
if (senderName) return Promise.resolve(senderName);
|
|
246
|
+
|
|
247
|
+
const senderId = params.sender?.id?.trim();
|
|
248
|
+
const senderIdType = params.sender?.id_type?.trim();
|
|
249
|
+
const senderType = params.sender?.sender_type?.trim();
|
|
250
|
+
if (!senderId) return Promise.resolve(undefined);
|
|
251
|
+
|
|
252
|
+
const localFallback = senderIdType ? `${senderIdType}:${senderId}` : senderId;
|
|
253
|
+
|
|
254
|
+
if (senderType === "app") {
|
|
255
|
+
const { displayName: configuredName, accountId: configuredAccountId } = resolveConfiguredAppLabel({
|
|
256
|
+
cfg: params.cfg,
|
|
257
|
+
appId: senderId,
|
|
258
|
+
});
|
|
259
|
+
if (configuredName) return Promise.resolve(configuredName);
|
|
260
|
+
|
|
261
|
+
if (senderIdType === "app_id") {
|
|
262
|
+
// For configured accounts, call /bot/v3/info with their own credentials to get the
|
|
263
|
+
// actual Feishu display name — no special application:read permissions required.
|
|
264
|
+
if (configuredAccountId !== undefined) {
|
|
265
|
+
const acct = resolveFeishuAccount({ cfg: params.cfg, accountId: configuredAccountId });
|
|
266
|
+
if (acct.configured) {
|
|
267
|
+
// Isolate client creation so a synchronous throw doesn't propagate to getMessageFeishu's
|
|
268
|
+
// outer catch and cause the entire message fetch to fail.
|
|
269
|
+
let botClient: ReturnType<typeof createFeishuClient>;
|
|
270
|
+
try {
|
|
271
|
+
botClient = createFeishuClient(acct);
|
|
272
|
+
} catch {
|
|
273
|
+
return Promise.resolve(configuredAccountId);
|
|
274
|
+
}
|
|
275
|
+
return resolveCurrentBotName({
|
|
276
|
+
client: botClient,
|
|
277
|
+
appId: senderId,
|
|
278
|
+
}).then((name) => name ?? configuredAccountId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Unconfigured bots: best-effort via application info API.
|
|
282
|
+
return resolveAppNameById({
|
|
283
|
+
client: params.client,
|
|
284
|
+
accountId: params.accountId,
|
|
285
|
+
appId: senderId,
|
|
286
|
+
}).then((name) => name ?? localFallback);
|
|
287
|
+
}
|
|
288
|
+
return Promise.resolve(localFallback);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Best-effort contact lookup to recover missing sender names in merge_forward children.
|
|
292
|
+
// Supported id types for contact API lookup: open_id / user_id / union_id.
|
|
293
|
+
const lookupType =
|
|
294
|
+
senderIdType === "open_id" || senderIdType === "user_id" || senderIdType === "union_id"
|
|
295
|
+
? senderIdType
|
|
296
|
+
: undefined;
|
|
297
|
+
const contactGet = params.client?.contact?.user?.get;
|
|
298
|
+
if (!lookupType || typeof contactGet !== "function") {
|
|
299
|
+
return Promise.resolve(localFallback);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const cacheKey = `${params.accountId}:${lookupType}:${senderId}`;
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
const cached = mergeForwardSenderNameCache.get(cacheKey);
|
|
305
|
+
if (cached && cached.expireAt > now) {
|
|
306
|
+
return Promise.resolve(cached.name);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return contactGet({
|
|
310
|
+
path: { user_id: senderId },
|
|
311
|
+
params: { user_id_type: lookupType },
|
|
312
|
+
})
|
|
313
|
+
.then((res: any) => {
|
|
314
|
+
const resolvedName =
|
|
315
|
+
res?.data?.user?.name ||
|
|
316
|
+
res?.data?.user?.nickname;
|
|
317
|
+
const finalName =
|
|
318
|
+
typeof resolvedName === "string" && resolvedName.trim() ? resolvedName.trim() : localFallback;
|
|
319
|
+
mergeForwardSenderNameCache.set(cacheKey, {
|
|
320
|
+
name: finalName,
|
|
321
|
+
expireAt: now + MERGE_FORWARD_NAME_CACHE_TTL_MS,
|
|
322
|
+
});
|
|
323
|
+
return finalName;
|
|
324
|
+
})
|
|
325
|
+
.catch(() => localFallback);
|
|
326
|
+
}
|
|
327
|
+
|
|
21
328
|
/**
|
|
22
329
|
* Get a message by its ID.
|
|
23
330
|
* Useful for fetching quoted/replied message content.
|
|
@@ -52,6 +359,12 @@ export async function getMessageFeishu(params: {
|
|
|
52
359
|
id_type?: string;
|
|
53
360
|
sender_type?: string;
|
|
54
361
|
};
|
|
362
|
+
mentions?: Array<{
|
|
363
|
+
key?: string;
|
|
364
|
+
id?: string;
|
|
365
|
+
id_type?: string;
|
|
366
|
+
name?: string;
|
|
367
|
+
}>;
|
|
55
368
|
create_time?: string;
|
|
56
369
|
}>;
|
|
57
370
|
};
|
|
@@ -61,55 +374,60 @@ export async function getMessageFeishu(params: {
|
|
|
61
374
|
return null;
|
|
62
375
|
}
|
|
63
376
|
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
377
|
+
const items = response.data?.items;
|
|
378
|
+
if (!items || items.length === 0) {
|
|
66
379
|
return null;
|
|
67
380
|
}
|
|
68
381
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
382
|
+
// For merge_forward type, items contains the merge message + child messages.
|
|
383
|
+
// Use child messages when present; otherwise fall back to first item for safety.
|
|
384
|
+
const isMergeForward = items[0]?.msg_type === "merge_forward";
|
|
385
|
+
const hasExpandedMergeForwardChildren = isMergeForward && items.length > 1;
|
|
386
|
+
const messagesToProcess = hasExpandedMergeForwardChildren ? items.slice(1) : items.slice(0, 1);
|
|
387
|
+
|
|
388
|
+
// Parse each message and combine content.
|
|
389
|
+
const parsedContents: string[] = [];
|
|
390
|
+
for (const item of messagesToProcess) {
|
|
391
|
+
const rawContent = item.body?.content ?? "";
|
|
392
|
+
const extractedContent = extractPlainTextFromMessageBody(rawContent, item.msg_type, item.mentions);
|
|
393
|
+
const content = isMergeForward
|
|
394
|
+
? replaceConfiguredAppIdTokens({
|
|
395
|
+
content: extractedContent,
|
|
396
|
+
cfg,
|
|
397
|
+
})
|
|
398
|
+
: extractedContent;
|
|
399
|
+
if (content.trim()) {
|
|
400
|
+
// Add sender label only for merge_forward expanded child messages.
|
|
401
|
+
let prefix = "";
|
|
402
|
+
if (hasExpandedMergeForwardChildren) {
|
|
403
|
+
const senderLabel = await buildMergeForwardSenderLabel({
|
|
404
|
+
cfg,
|
|
405
|
+
rawContent,
|
|
406
|
+
client,
|
|
407
|
+
accountId: account.accountId,
|
|
408
|
+
sender: item.sender,
|
|
409
|
+
});
|
|
410
|
+
if (senderLabel) {
|
|
411
|
+
prefix = `[${senderLabel}] `;
|
|
412
|
+
}
|
|
99
413
|
}
|
|
414
|
+
parsedContents.push(prefix + content.trim());
|
|
100
415
|
}
|
|
101
|
-
} catch {
|
|
102
|
-
// Keep raw content if parsing fails
|
|
103
416
|
}
|
|
104
417
|
|
|
418
|
+
const combinedContent = hasExpandedMergeForwardChildren
|
|
419
|
+
? parsedContents.join("\n\n---\n\n")
|
|
420
|
+
: (parsedContents[0] ?? "");
|
|
421
|
+
const firstItem = items[0];
|
|
422
|
+
|
|
105
423
|
return {
|
|
106
|
-
messageId:
|
|
107
|
-
chatId:
|
|
108
|
-
senderId:
|
|
109
|
-
senderOpenId:
|
|
110
|
-
content,
|
|
111
|
-
contentType:
|
|
112
|
-
createTime:
|
|
424
|
+
messageId: firstItem?.message_id ?? messageId,
|
|
425
|
+
chatId: firstItem?.chat_id ?? "",
|
|
426
|
+
senderId: firstItem?.sender?.id,
|
|
427
|
+
senderOpenId: firstItem?.sender?.id_type === "open_id" ? firstItem?.sender?.id : undefined,
|
|
428
|
+
content: combinedContent,
|
|
429
|
+
contentType: firstItem?.msg_type ?? "text",
|
|
430
|
+
createTime: firstItem?.create_time ? parseInt(firstItem.create_time, 10) : undefined,
|
|
113
431
|
};
|
|
114
432
|
} catch {
|
|
115
433
|
return null;
|
package/src/streaming-card.ts
CHANGED
|
@@ -44,6 +44,25 @@ async function getToken(creds: Credentials): Promise<string> {
|
|
|
44
44
|
return data.tenant_access_token;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function mergeStreamingText(
|
|
48
|
+
previousText: string | undefined,
|
|
49
|
+
nextText: string | undefined,
|
|
50
|
+
): string {
|
|
51
|
+
const previous = typeof previousText === "string" ? previousText : "";
|
|
52
|
+
const next = typeof nextText === "string" ? nextText : "";
|
|
53
|
+
if (!next) {
|
|
54
|
+
return previous;
|
|
55
|
+
}
|
|
56
|
+
if (!previous || next === previous || next.includes(previous)) {
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
if (previous.includes(next)) {
|
|
60
|
+
return previous;
|
|
61
|
+
}
|
|
62
|
+
// Fallback for fragmented partial chunks: append to avoid losing tokens.
|
|
63
|
+
return `${previous}${next}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
function truncateSummary(text: string, max = 50): string {
|
|
48
67
|
if (!text) {
|
|
49
68
|
return "";
|
|
@@ -131,9 +150,13 @@ export class FeishuStreamingSession {
|
|
|
131
150
|
if (!this.state || this.closed) {
|
|
132
151
|
return;
|
|
133
152
|
}
|
|
153
|
+
const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
|
|
154
|
+
if (!mergedInput || mergedInput === this.state.currentText) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
134
157
|
const now = Date.now();
|
|
135
158
|
if (now - this.lastUpdateTime < this.updateThrottleMs) {
|
|
136
|
-
this.pendingText =
|
|
159
|
+
this.pendingText = mergedInput;
|
|
137
160
|
return;
|
|
138
161
|
}
|
|
139
162
|
this.pendingText = null;
|
|
@@ -143,7 +166,11 @@ export class FeishuStreamingSession {
|
|
|
143
166
|
if (!this.state || this.closed) {
|
|
144
167
|
return;
|
|
145
168
|
}
|
|
146
|
-
this.state.currentText
|
|
169
|
+
const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
|
|
170
|
+
if (!mergedText || mergedText === this.state.currentText) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.state.currentText = mergedText;
|
|
147
174
|
this.state.sequence += 1;
|
|
148
175
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
149
176
|
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
|
@@ -153,7 +180,7 @@ export class FeishuStreamingSession {
|
|
|
153
180
|
"Content-Type": "application/json",
|
|
154
181
|
},
|
|
155
182
|
body: JSON.stringify({
|
|
156
|
-
content:
|
|
183
|
+
content: mergedText,
|
|
157
184
|
sequence: this.state.sequence,
|
|
158
185
|
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
159
186
|
}),
|
|
@@ -169,7 +196,8 @@ export class FeishuStreamingSession {
|
|
|
169
196
|
this.closed = true;
|
|
170
197
|
await this.queue;
|
|
171
198
|
|
|
172
|
-
const
|
|
199
|
+
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
|
|
200
|
+
const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
|
|
173
201
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
174
202
|
|
|
175
203
|
if (text && text !== this.state.currentText) {
|