@tobeyoureyes/feishu 1.0.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.md +290 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +42 -0
- package/src/api.ts +1160 -0
- package/src/auth.ts +133 -0
- package/src/channel.ts +883 -0
- package/src/context.ts +292 -0
- package/src/dedupe.ts +85 -0
- package/src/dispatch.ts +185 -0
- package/src/history.ts +130 -0
- package/src/inbound.ts +83 -0
- package/src/message.ts +386 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +330 -0
- package/src/webhook.ts +549 -0
- package/src/websocket.ts +372 -0
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu webhook event handling
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
FeishuUrlVerificationEvent,
|
|
7
|
+
FeishuMessageReceiveEvent,
|
|
8
|
+
FeishuBotAddedEvent,
|
|
9
|
+
FeishuBotRemovedEvent,
|
|
10
|
+
ResolvedFeishuAccount,
|
|
11
|
+
FeishuMsgType,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
export interface FeishuInboundMessage {
|
|
15
|
+
messageId: string;
|
|
16
|
+
chatId: string;
|
|
17
|
+
chatType: "direct" | "group";
|
|
18
|
+
senderId: string;
|
|
19
|
+
senderOpenId?: string;
|
|
20
|
+
senderUserId?: string;
|
|
21
|
+
senderUnionId?: string;
|
|
22
|
+
messageType: FeishuMsgType;
|
|
23
|
+
/** Raw content JSON string */
|
|
24
|
+
content: string;
|
|
25
|
+
/** Raw text with placeholders like @_user_1 */
|
|
26
|
+
text?: string;
|
|
27
|
+
/** Display text with placeholders replaced by actual names */
|
|
28
|
+
displayText?: string;
|
|
29
|
+
mentions?: Array<{
|
|
30
|
+
key: string;
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
}>;
|
|
34
|
+
rootId?: string;
|
|
35
|
+
parentId?: string;
|
|
36
|
+
createTime: number;
|
|
37
|
+
tenantKey: string;
|
|
38
|
+
|
|
39
|
+
// Reply context fields (populated when message is a reply)
|
|
40
|
+
/** The text content of the replied-to message */
|
|
41
|
+
replyToBody?: string;
|
|
42
|
+
/** The sender ID of the replied-to message */
|
|
43
|
+
replyToSenderId?: string;
|
|
44
|
+
/** The message ID of the replied-to message */
|
|
45
|
+
replyToId?: string;
|
|
46
|
+
|
|
47
|
+
// Media fields (for image/file/audio messages)
|
|
48
|
+
/** Image key for image messages */
|
|
49
|
+
imageKey?: string;
|
|
50
|
+
/** File key for file messages */
|
|
51
|
+
fileKey?: string;
|
|
52
|
+
/** Original file name */
|
|
53
|
+
fileName?: string;
|
|
54
|
+
/** Local path after download */
|
|
55
|
+
mediaPath?: string;
|
|
56
|
+
/** Media MIME type */
|
|
57
|
+
mediaType?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface FeishuWebhookResult {
|
|
61
|
+
type: "challenge" | "message" | "bot_added" | "bot_removed" | "unknown" | "error";
|
|
62
|
+
challenge?: string;
|
|
63
|
+
message?: FeishuInboundMessage;
|
|
64
|
+
chatId?: string;
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse and handle incoming Feishu webhook event
|
|
70
|
+
*/
|
|
71
|
+
export function parseWebhookEvent(
|
|
72
|
+
body: unknown,
|
|
73
|
+
account: ResolvedFeishuAccount,
|
|
74
|
+
): FeishuWebhookResult {
|
|
75
|
+
try {
|
|
76
|
+
// Handle URL verification challenge
|
|
77
|
+
if (isUrlVerification(body)) {
|
|
78
|
+
return handleUrlVerification(body, account);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle event callback
|
|
82
|
+
if (isEventCallback(body)) {
|
|
83
|
+
return handleEventCallback(body, account);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { type: "unknown" };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
return { type: "error", error: message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if the body is a URL verification request
|
|
95
|
+
*/
|
|
96
|
+
function isUrlVerification(body: unknown): body is FeishuUrlVerificationEvent {
|
|
97
|
+
return (
|
|
98
|
+
typeof body === "object" &&
|
|
99
|
+
body !== null &&
|
|
100
|
+
"type" in body &&
|
|
101
|
+
(body as { type: string }).type === "url_verification"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if the body is an event callback
|
|
107
|
+
*/
|
|
108
|
+
function isEventCallback(body: unknown): body is { schema: string; header: { event_type: string } } {
|
|
109
|
+
return (
|
|
110
|
+
typeof body === "object" &&
|
|
111
|
+
body !== null &&
|
|
112
|
+
"schema" in body &&
|
|
113
|
+
"header" in body &&
|
|
114
|
+
typeof (body as { header: unknown }).header === "object"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Handle URL verification challenge
|
|
120
|
+
*/
|
|
121
|
+
function handleUrlVerification(
|
|
122
|
+
event: FeishuUrlVerificationEvent,
|
|
123
|
+
account: ResolvedFeishuAccount,
|
|
124
|
+
): FeishuWebhookResult {
|
|
125
|
+
// Verify token if configured
|
|
126
|
+
if (account.verificationToken && event.token !== account.verificationToken) {
|
|
127
|
+
return { type: "error", error: "Invalid verification token" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
type: "challenge",
|
|
132
|
+
challenge: event.challenge,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Handle event callback
|
|
138
|
+
*/
|
|
139
|
+
function handleEventCallback(
|
|
140
|
+
body: { schema: string; header: { event_type: string }; event?: unknown },
|
|
141
|
+
account: ResolvedFeishuAccount,
|
|
142
|
+
): FeishuWebhookResult {
|
|
143
|
+
const eventType = body.header.event_type;
|
|
144
|
+
|
|
145
|
+
switch (eventType) {
|
|
146
|
+
case "im.message.receive_v1":
|
|
147
|
+
return handleMessageReceive(body as FeishuMessageReceiveEvent, account);
|
|
148
|
+
|
|
149
|
+
case "im.chat.member.bot.added_v1":
|
|
150
|
+
return handleBotAdded(body as FeishuBotAddedEvent);
|
|
151
|
+
|
|
152
|
+
case "im.chat.member.bot.deleted_v1":
|
|
153
|
+
return handleBotRemoved(body as FeishuBotRemovedEvent);
|
|
154
|
+
|
|
155
|
+
default:
|
|
156
|
+
return { type: "unknown" };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Handle message receive event
|
|
162
|
+
*/
|
|
163
|
+
function handleMessageReceive(
|
|
164
|
+
event: FeishuMessageReceiveEvent,
|
|
165
|
+
_account: ResolvedFeishuAccount,
|
|
166
|
+
): FeishuWebhookResult {
|
|
167
|
+
const { sender, message } = event.event;
|
|
168
|
+
|
|
169
|
+
// Parse message content
|
|
170
|
+
let text: string | undefined;
|
|
171
|
+
let imageKey: string | undefined;
|
|
172
|
+
let fileKey: string | undefined;
|
|
173
|
+
let fileName: string | undefined;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const contentObj = JSON.parse(message.content);
|
|
177
|
+
|
|
178
|
+
switch (message.message_type) {
|
|
179
|
+
case "text":
|
|
180
|
+
text = contentObj.text;
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case "post":
|
|
184
|
+
// Extract text from post content
|
|
185
|
+
text = extractTextFromPost(contentObj);
|
|
186
|
+
break;
|
|
187
|
+
|
|
188
|
+
case "image":
|
|
189
|
+
// Image message: { image_key: "xxx" }
|
|
190
|
+
imageKey = contentObj.image_key;
|
|
191
|
+
text = "<media:image>";
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
case "file":
|
|
195
|
+
// File message: { file_key: "xxx", file_name: "xxx" }
|
|
196
|
+
fileKey = contentObj.file_key;
|
|
197
|
+
fileName = contentObj.file_name;
|
|
198
|
+
text = `<media:file>${fileName ?? "file"}`;
|
|
199
|
+
break;
|
|
200
|
+
|
|
201
|
+
case "audio":
|
|
202
|
+
// Audio message: { file_key: "xxx", duration: xxx }
|
|
203
|
+
fileKey = contentObj.file_key;
|
|
204
|
+
text = `<media:audio>`;
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
case "media":
|
|
208
|
+
// Media message (video): { file_key: "xxx", image_key: "xxx" }
|
|
209
|
+
fileKey = contentObj.file_key;
|
|
210
|
+
imageKey = contentObj.image_key;
|
|
211
|
+
text = `<media:video>`;
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
case "sticker":
|
|
215
|
+
// Sticker message: { file_key: "xxx" }
|
|
216
|
+
fileKey = contentObj.file_key;
|
|
217
|
+
text = `<media:sticker>`;
|
|
218
|
+
break;
|
|
219
|
+
|
|
220
|
+
case "interactive":
|
|
221
|
+
// Card message - extract text from elements
|
|
222
|
+
text = extractTextFromCard(contentObj);
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
case "share_chat":
|
|
226
|
+
// Shared chat: { chat_id: "xxx" }
|
|
227
|
+
text = `[Shared chat: ${contentObj.chat_id}]`;
|
|
228
|
+
break;
|
|
229
|
+
|
|
230
|
+
case "share_user":
|
|
231
|
+
// Shared user: { user_id: "xxx" }
|
|
232
|
+
text = `[Shared user: ${contentObj.user_id}]`;
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
default:
|
|
236
|
+
// Unknown message type - try to extract as text
|
|
237
|
+
text = message.content;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Content might not be JSON
|
|
241
|
+
text = message.content;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Map mentions
|
|
245
|
+
const mentions = message.mentions?.map((m) => ({
|
|
246
|
+
key: m.key,
|
|
247
|
+
id: m.id.open_id || m.id.user_id || m.id.union_id || "",
|
|
248
|
+
name: m.name,
|
|
249
|
+
}));
|
|
250
|
+
|
|
251
|
+
// Generate display text with mention placeholders replaced
|
|
252
|
+
let displayText = text;
|
|
253
|
+
if (text && mentions && mentions.length > 0) {
|
|
254
|
+
for (const mention of mentions) {
|
|
255
|
+
displayText = displayText?.replace(new RegExp(mention.key, "g"), `@${mention.name}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const inboundMessage: FeishuInboundMessage = {
|
|
260
|
+
messageId: message.message_id,
|
|
261
|
+
chatId: message.chat_id,
|
|
262
|
+
chatType: message.chat_type === "p2p" ? "direct" : "group",
|
|
263
|
+
senderId: sender.sender_id.open_id || sender.sender_id.user_id || sender.sender_id.union_id || "",
|
|
264
|
+
senderOpenId: sender.sender_id.open_id,
|
|
265
|
+
senderUserId: sender.sender_id.user_id,
|
|
266
|
+
senderUnionId: sender.sender_id.union_id,
|
|
267
|
+
messageType: message.message_type,
|
|
268
|
+
content: message.content,
|
|
269
|
+
text,
|
|
270
|
+
displayText,
|
|
271
|
+
mentions,
|
|
272
|
+
rootId: message.root_id,
|
|
273
|
+
parentId: message.parent_id,
|
|
274
|
+
createTime: parseInt(message.create_time, 10),
|
|
275
|
+
tenantKey: sender.tenant_key,
|
|
276
|
+
// Media fields
|
|
277
|
+
imageKey,
|
|
278
|
+
fileKey,
|
|
279
|
+
fileName,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
type: "message",
|
|
284
|
+
message: inboundMessage,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Extract text from interactive card content
|
|
290
|
+
*/
|
|
291
|
+
function extractTextFromCard(content: unknown): string {
|
|
292
|
+
const texts: string[] = [];
|
|
293
|
+
|
|
294
|
+
if (typeof content !== "object" || content === null) {
|
|
295
|
+
return "";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const card = content as {
|
|
299
|
+
header?: { title?: { content?: string } };
|
|
300
|
+
elements?: Array<{ tag?: string; content?: string; text?: { content?: string } }>;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Extract header title
|
|
304
|
+
if (card.header?.title?.content) {
|
|
305
|
+
texts.push(card.header.title.content);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Extract element contents
|
|
309
|
+
if (Array.isArray(card.elements)) {
|
|
310
|
+
for (const element of card.elements) {
|
|
311
|
+
if (element.tag === "markdown" && element.content) {
|
|
312
|
+
texts.push(element.content);
|
|
313
|
+
} else if (element.tag === "div" && element.text?.content) {
|
|
314
|
+
texts.push(element.text.content);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return texts.join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Handle bot added to chat event
|
|
324
|
+
*/
|
|
325
|
+
function handleBotAdded(event: FeishuBotAddedEvent): FeishuWebhookResult {
|
|
326
|
+
return {
|
|
327
|
+
type: "bot_added",
|
|
328
|
+
chatId: event.event.chat_id,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Handle bot removed from chat event
|
|
334
|
+
*/
|
|
335
|
+
function handleBotRemoved(event: FeishuBotRemovedEvent): FeishuWebhookResult {
|
|
336
|
+
return {
|
|
337
|
+
type: "bot_removed",
|
|
338
|
+
chatId: event.event.chat_id,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Extract plain text from post content
|
|
344
|
+
*/
|
|
345
|
+
function extractTextFromPost(content: unknown): string {
|
|
346
|
+
const texts: string[] = [];
|
|
347
|
+
|
|
348
|
+
function extractFromElements(elements: unknown[]): void {
|
|
349
|
+
for (const element of elements) {
|
|
350
|
+
if (typeof element !== "object" || element === null) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const el = element as { tag?: string; text?: string; content?: unknown[][] };
|
|
355
|
+
|
|
356
|
+
if (el.tag === "text" && el.text) {
|
|
357
|
+
texts.push(el.text);
|
|
358
|
+
} else if (el.tag === "a" && el.text) {
|
|
359
|
+
texts.push(el.text);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (typeof content === "object" && content !== null) {
|
|
365
|
+
const post = content as {
|
|
366
|
+
zh_cn?: { content?: unknown[][] };
|
|
367
|
+
en_us?: { content?: unknown[][] };
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// Try zh_cn first, then en_us
|
|
371
|
+
const postContent = post.zh_cn?.content || post.en_us?.content;
|
|
372
|
+
|
|
373
|
+
if (Array.isArray(postContent)) {
|
|
374
|
+
for (const line of postContent) {
|
|
375
|
+
if (Array.isArray(line)) {
|
|
376
|
+
extractFromElements(line);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return texts.join(" ");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Verify webhook request signature
|
|
387
|
+
*/
|
|
388
|
+
export function verifySignature(
|
|
389
|
+
timestamp: string,
|
|
390
|
+
nonce: string,
|
|
391
|
+
signature: string,
|
|
392
|
+
body: string,
|
|
393
|
+
encryptKey: string,
|
|
394
|
+
): boolean {
|
|
395
|
+
// Feishu webhook signature verification
|
|
396
|
+
// signature = sha256(timestamp + nonce + encryptKey + body)
|
|
397
|
+
// For now, we'll implement basic verification
|
|
398
|
+
// TODO: Implement proper HMAC-SHA256 verification
|
|
399
|
+
|
|
400
|
+
if (!encryptKey) {
|
|
401
|
+
return true; // No encryption configured, skip verification
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Placeholder for actual verification
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Decrypt encrypted webhook body
|
|
410
|
+
*/
|
|
411
|
+
export function decryptBody(encryptedBody: string, encryptKey: string): string {
|
|
412
|
+
// Feishu uses AES-256-CBC for encryption
|
|
413
|
+
// The encrypted body is base64 encoded
|
|
414
|
+
// TODO: Implement proper AES decryption
|
|
415
|
+
|
|
416
|
+
if (!encryptKey) {
|
|
417
|
+
return encryptedBody;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Placeholder for actual decryption
|
|
421
|
+
return encryptedBody;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create the challenge response for URL verification
|
|
426
|
+
*/
|
|
427
|
+
export function createChallengeResponse(challenge: string): { challenge: string } {
|
|
428
|
+
return { challenge };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Check if a message mentions the bot
|
|
433
|
+
* @param message - The inbound message
|
|
434
|
+
* @param botOpenId - The bot's open_id (optional)
|
|
435
|
+
* @param botName - The bot's name to match (optional, e.g., "OpenClaw")
|
|
436
|
+
*/
|
|
437
|
+
export function isBotMentioned(
|
|
438
|
+
message: FeishuInboundMessage,
|
|
439
|
+
botOpenId?: string,
|
|
440
|
+
botName?: string,
|
|
441
|
+
): boolean {
|
|
442
|
+
if (!message.mentions || message.mentions.length === 0) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return message.mentions.some((m) => {
|
|
447
|
+
// Check by open_id
|
|
448
|
+
if (botOpenId && m.id === botOpenId) {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
// Check by name
|
|
452
|
+
if (botName && m.name === botName) {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
// Check for @all
|
|
456
|
+
if (m.key === "@_all") {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get the text content without the bot mention
|
|
465
|
+
* Removes the @bot placeholder and cleans up whitespace
|
|
466
|
+
*/
|
|
467
|
+
export function getTextWithoutBotMention(
|
|
468
|
+
text: string,
|
|
469
|
+
mentions: Array<{ key: string; name: string; id: string }> | undefined,
|
|
470
|
+
botOpenId?: string,
|
|
471
|
+
botName?: string,
|
|
472
|
+
): string {
|
|
473
|
+
if (!text || !mentions || mentions.length === 0) {
|
|
474
|
+
return text;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let result = text;
|
|
478
|
+
|
|
479
|
+
for (const mention of mentions) {
|
|
480
|
+
const isBotMention =
|
|
481
|
+
(botOpenId && mention.id === botOpenId) ||
|
|
482
|
+
(botName && mention.name === botName);
|
|
483
|
+
|
|
484
|
+
if (isBotMention) {
|
|
485
|
+
// Remove bot mention
|
|
486
|
+
result = result.replace(new RegExp(mention.key, "g"), "");
|
|
487
|
+
} else {
|
|
488
|
+
// Replace other mentions with @name
|
|
489
|
+
result = result.replace(new RegExp(mention.key, "g"), `@${mention.name}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Clean up extra whitespace
|
|
494
|
+
return result.replace(/\s+/g, " ").trim();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Strip bot mention from message text
|
|
499
|
+
*/
|
|
500
|
+
export function stripBotMention(
|
|
501
|
+
text: string,
|
|
502
|
+
mentions: Array<{ key: string; name: string }> | undefined,
|
|
503
|
+
_botOpenId?: string,
|
|
504
|
+
): string {
|
|
505
|
+
if (!text || !mentions || mentions.length === 0) {
|
|
506
|
+
return text;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
let result = text;
|
|
510
|
+
|
|
511
|
+
for (const mention of mentions) {
|
|
512
|
+
// Replace mention placeholders like @_user_1
|
|
513
|
+
result = result.replace(new RegExp(mention.key, "g"), "").trim();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Replace mention placeholders with actual names
|
|
521
|
+
* e.g., "@_user_1 hello" -> "@张三 hello"
|
|
522
|
+
*/
|
|
523
|
+
export function replaceMentionPlaceholders(
|
|
524
|
+
text: string,
|
|
525
|
+
mentions: Array<{ key: string; name: string }> | undefined,
|
|
526
|
+
): string {
|
|
527
|
+
if (!text || !mentions || mentions.length === 0) {
|
|
528
|
+
return text;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let result = text;
|
|
532
|
+
|
|
533
|
+
for (const mention of mentions) {
|
|
534
|
+
// Replace @_user_1 with @实际名称
|
|
535
|
+
result = result.replace(new RegExp(mention.key, "g"), `@${mention.name}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return result;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Format text for display, replacing all mention placeholders
|
|
543
|
+
*/
|
|
544
|
+
export function formatMessageText(
|
|
545
|
+
text: string,
|
|
546
|
+
mentions: Array<{ key: string; name: string }> | undefined,
|
|
547
|
+
): string {
|
|
548
|
+
return replaceMentionPlaceholders(text, mentions);
|
|
549
|
+
}
|