@colin3191/feishu 0.1.2

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.
@@ -0,0 +1,156 @@
1
+ import {
2
+ createReplyPrefixContext,
3
+ createTypingCallbacks,
4
+ logTypingFailure,
5
+ type ClawdbotConfig,
6
+ type RuntimeEnv,
7
+ type ReplyPayload,
8
+ } from "clawdbot/plugin-sdk";
9
+ import { getFeishuRuntime } from "./runtime.js";
10
+ import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
11
+ import type { FeishuConfig } from "./types.js";
12
+ import {
13
+ addTypingIndicator,
14
+ removeTypingIndicator,
15
+ type TypingIndicatorState,
16
+ } from "./typing.js";
17
+
18
+ /**
19
+ * Detect if text contains markdown elements that benefit from card rendering.
20
+ * Used by auto render mode.
21
+ */
22
+ function shouldUseCard(text: string): boolean {
23
+ // Code blocks (fenced)
24
+ if (/```[\s\S]*?```/.test(text)) return true;
25
+ // Tables (at least header + separator row with |)
26
+ if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
27
+ return false;
28
+ }
29
+
30
+ export type CreateFeishuReplyDispatcherParams = {
31
+ cfg: ClawdbotConfig;
32
+ agentId: string;
33
+ runtime: RuntimeEnv;
34
+ chatId: string;
35
+ replyToMessageId?: string;
36
+ };
37
+
38
+ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
39
+ const core = getFeishuRuntime();
40
+ const { cfg, agentId, chatId, replyToMessageId } = params;
41
+
42
+ const prefixContext = createReplyPrefixContext({
43
+ cfg,
44
+ agentId,
45
+ });
46
+
47
+ // Feishu doesn't have a native typing indicator API.
48
+ // We use message reactions as a typing indicator substitute.
49
+ let typingState: TypingIndicatorState | null = null;
50
+
51
+ const typingCallbacks = createTypingCallbacks({
52
+ start: async () => {
53
+ if (!replyToMessageId) return;
54
+ typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId });
55
+ params.runtime.log?.(`feishu: added typing indicator reaction`);
56
+ },
57
+ stop: async () => {
58
+ if (!typingState) return;
59
+ await removeTypingIndicator({ cfg, state: typingState });
60
+ typingState = null;
61
+ params.runtime.log?.(`feishu: removed typing indicator reaction`);
62
+ },
63
+ onStartError: (err) => {
64
+ logTypingFailure({
65
+ log: (message) => params.runtime.log?.(message),
66
+ channel: "feishu",
67
+ action: "start",
68
+ error: err,
69
+ });
70
+ },
71
+ onStopError: (err) => {
72
+ logTypingFailure({
73
+ log: (message) => params.runtime.log?.(message),
74
+ channel: "feishu",
75
+ action: "stop",
76
+ error: err,
77
+ });
78
+ },
79
+ });
80
+
81
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit({
82
+ cfg,
83
+ channel: "feishu",
84
+ defaultLimit: 4000,
85
+ });
86
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
87
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
88
+ cfg,
89
+ channel: "feishu",
90
+ });
91
+
92
+ const { dispatcher, replyOptions, markDispatchIdle } =
93
+ core.channel.reply.createReplyDispatcherWithTyping({
94
+ responsePrefix: prefixContext.responsePrefix,
95
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
96
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
97
+ onReplyStart: typingCallbacks.onReplyStart,
98
+ deliver: async (payload: ReplyPayload) => {
99
+ params.runtime.log?.(`feishu deliver called: text=${payload.text?.slice(0, 100)}`);
100
+ const text = payload.text ?? "";
101
+ if (!text.trim()) {
102
+ params.runtime.log?.(`feishu deliver: empty text, skipping`);
103
+ return;
104
+ }
105
+
106
+ // Check render mode: auto (default), raw, or card
107
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
108
+ const renderMode = feishuCfg?.renderMode ?? "auto";
109
+
110
+ // Determine if we should use card for this message
111
+ const useCard =
112
+ renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
113
+
114
+ if (useCard) {
115
+ // Card mode: send as interactive card with markdown rendering
116
+ const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
117
+ params.runtime.log?.(`feishu deliver: sending ${chunks.length} card chunks to ${chatId}`);
118
+ for (const chunk of chunks) {
119
+ await sendMarkdownCardFeishu({
120
+ cfg,
121
+ to: chatId,
122
+ text: chunk,
123
+ replyToMessageId,
124
+ });
125
+ }
126
+ } else {
127
+ // Raw mode: send as plain text with table conversion
128
+ const converted = core.channel.text.convertMarkdownTables(text, tableMode);
129
+ const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
130
+ params.runtime.log?.(`feishu deliver: sending ${chunks.length} text chunks to ${chatId}`);
131
+ for (const chunk of chunks) {
132
+ await sendMessageFeishu({
133
+ cfg,
134
+ to: chatId,
135
+ text: chunk,
136
+ replyToMessageId,
137
+ });
138
+ }
139
+ }
140
+ },
141
+ onError: (err, info) => {
142
+ params.runtime.error?.(`feishu ${info.kind} reply failed: ${String(err)}`);
143
+ typingCallbacks.onIdle?.();
144
+ },
145
+ onIdle: typingCallbacks.onIdle,
146
+ });
147
+
148
+ return {
149
+ dispatcher,
150
+ replyOptions: {
151
+ ...replyOptions,
152
+ onModelSelected: prefixContext.onModelSelected,
153
+ },
154
+ markDispatchIdle,
155
+ };
156
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "clawdbot/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setFeishuRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getFeishuRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("Feishu runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
package/src/send.ts ADDED
@@ -0,0 +1,308 @@
1
+ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
2
+ import type { FeishuConfig, FeishuSendResult } from "./types.js";
3
+ import { createFeishuClient } from "./client.js";
4
+ import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
5
+ import { getFeishuRuntime } from "./runtime.js";
6
+
7
+ export type FeishuMessageInfo = {
8
+ messageId: string;
9
+ chatId: string;
10
+ senderId?: string;
11
+ senderOpenId?: string;
12
+ content: string;
13
+ contentType: string;
14
+ createTime?: number;
15
+ };
16
+
17
+ /**
18
+ * Get a message by its ID.
19
+ * Useful for fetching quoted/replied message content.
20
+ */
21
+ export async function getMessageFeishu(params: {
22
+ cfg: ClawdbotConfig;
23
+ messageId: string;
24
+ }): Promise<FeishuMessageInfo | null> {
25
+ const { cfg, messageId } = params;
26
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
27
+ if (!feishuCfg) {
28
+ throw new Error("Feishu channel not configured");
29
+ }
30
+
31
+ const client = createFeishuClient(feishuCfg);
32
+
33
+ try {
34
+ const response = (await client.im.message.get({
35
+ path: { message_id: messageId },
36
+ })) as {
37
+ code?: number;
38
+ msg?: string;
39
+ data?: {
40
+ items?: Array<{
41
+ message_id?: string;
42
+ chat_id?: string;
43
+ msg_type?: string;
44
+ body?: { content?: string };
45
+ sender?: {
46
+ id?: string;
47
+ id_type?: string;
48
+ sender_type?: string;
49
+ };
50
+ create_time?: string;
51
+ }>;
52
+ };
53
+ };
54
+
55
+ if (response.code !== 0) {
56
+ return null;
57
+ }
58
+
59
+ const item = response.data?.items?.[0];
60
+ if (!item) {
61
+ return null;
62
+ }
63
+
64
+ // Parse content based on message type
65
+ let content = item.body?.content ?? "";
66
+ try {
67
+ const parsed = JSON.parse(content);
68
+ if (item.msg_type === "text" && parsed.text) {
69
+ content = parsed.text;
70
+ }
71
+ } catch {
72
+ // Keep raw content if parsing fails
73
+ }
74
+
75
+ return {
76
+ messageId: item.message_id ?? messageId,
77
+ chatId: item.chat_id ?? "",
78
+ senderId: item.sender?.id,
79
+ senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
80
+ content,
81
+ contentType: item.msg_type ?? "text",
82
+ createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
83
+ };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export type SendFeishuMessageParams = {
90
+ cfg: ClawdbotConfig;
91
+ to: string;
92
+ text: string;
93
+ replyToMessageId?: string;
94
+ };
95
+
96
+ export async function sendMessageFeishu(params: SendFeishuMessageParams): Promise<FeishuSendResult> {
97
+ const { cfg, to, text, replyToMessageId } = params;
98
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
99
+ if (!feishuCfg) {
100
+ throw new Error("Feishu channel not configured");
101
+ }
102
+
103
+ const client = createFeishuClient(feishuCfg);
104
+ const receiveId = normalizeFeishuTarget(to);
105
+ if (!receiveId) {
106
+ throw new Error(`Invalid Feishu target: ${to}`);
107
+ }
108
+
109
+ const receiveIdType = resolveReceiveIdType(receiveId);
110
+ const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
111
+ cfg,
112
+ channel: "feishu",
113
+ });
114
+ const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
115
+
116
+ const content = JSON.stringify({ text: messageText });
117
+
118
+ if (replyToMessageId) {
119
+ const response = await client.im.message.reply({
120
+ path: { message_id: replyToMessageId },
121
+ data: {
122
+ content,
123
+ msg_type: "text",
124
+ },
125
+ });
126
+
127
+ if (response.code !== 0) {
128
+ throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
129
+ }
130
+
131
+ return {
132
+ messageId: response.data?.message_id ?? "unknown",
133
+ chatId: receiveId,
134
+ };
135
+ }
136
+
137
+ const response = await client.im.message.create({
138
+ params: { receive_id_type: receiveIdType },
139
+ data: {
140
+ receive_id: receiveId,
141
+ content,
142
+ msg_type: "text",
143
+ },
144
+ });
145
+
146
+ if (response.code !== 0) {
147
+ throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
148
+ }
149
+
150
+ return {
151
+ messageId: response.data?.message_id ?? "unknown",
152
+ chatId: receiveId,
153
+ };
154
+ }
155
+
156
+ export type SendFeishuCardParams = {
157
+ cfg: ClawdbotConfig;
158
+ to: string;
159
+ card: Record<string, unknown>;
160
+ replyToMessageId?: string;
161
+ };
162
+
163
+ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
164
+ const { cfg, to, card, replyToMessageId } = params;
165
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
166
+ if (!feishuCfg) {
167
+ throw new Error("Feishu channel not configured");
168
+ }
169
+
170
+ const client = createFeishuClient(feishuCfg);
171
+ const receiveId = normalizeFeishuTarget(to);
172
+ if (!receiveId) {
173
+ throw new Error(`Invalid Feishu target: ${to}`);
174
+ }
175
+
176
+ const receiveIdType = resolveReceiveIdType(receiveId);
177
+ const content = JSON.stringify(card);
178
+
179
+ if (replyToMessageId) {
180
+ const response = await client.im.message.reply({
181
+ path: { message_id: replyToMessageId },
182
+ data: {
183
+ content,
184
+ msg_type: "interactive",
185
+ },
186
+ });
187
+
188
+ if (response.code !== 0) {
189
+ throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
190
+ }
191
+
192
+ return {
193
+ messageId: response.data?.message_id ?? "unknown",
194
+ chatId: receiveId,
195
+ };
196
+ }
197
+
198
+ const response = await client.im.message.create({
199
+ params: { receive_id_type: receiveIdType },
200
+ data: {
201
+ receive_id: receiveId,
202
+ content,
203
+ msg_type: "interactive",
204
+ },
205
+ });
206
+
207
+ if (response.code !== 0) {
208
+ throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
209
+ }
210
+
211
+ return {
212
+ messageId: response.data?.message_id ?? "unknown",
213
+ chatId: receiveId,
214
+ };
215
+ }
216
+
217
+ export async function updateCardFeishu(params: {
218
+ cfg: ClawdbotConfig;
219
+ messageId: string;
220
+ card: Record<string, unknown>;
221
+ }): Promise<void> {
222
+ const { cfg, messageId, card } = params;
223
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
224
+ if (!feishuCfg) {
225
+ throw new Error("Feishu channel not configured");
226
+ }
227
+
228
+ const client = createFeishuClient(feishuCfg);
229
+ const content = JSON.stringify(card);
230
+
231
+ const response = await client.im.message.patch({
232
+ path: { message_id: messageId },
233
+ data: { content },
234
+ });
235
+
236
+ if (response.code !== 0) {
237
+ throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Build a Feishu interactive card with markdown content.
243
+ * Cards render markdown properly (code blocks, tables, links, etc.)
244
+ */
245
+ export function buildMarkdownCard(text: string): Record<string, unknown> {
246
+ return {
247
+ config: {
248
+ wide_screen_mode: true,
249
+ },
250
+ elements: [
251
+ {
252
+ tag: "markdown",
253
+ content: text,
254
+ },
255
+ ],
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Send a message as a markdown card (interactive message).
261
+ * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
262
+ */
263
+ export async function sendMarkdownCardFeishu(params: {
264
+ cfg: ClawdbotConfig;
265
+ to: string;
266
+ text: string;
267
+ replyToMessageId?: string;
268
+ }): Promise<FeishuSendResult> {
269
+ const { cfg, to, text, replyToMessageId } = params;
270
+ const card = buildMarkdownCard(text);
271
+ return sendCardFeishu({ cfg, to, card, replyToMessageId });
272
+ }
273
+
274
+ /**
275
+ * Edit an existing text message.
276
+ * Note: Feishu only allows editing messages within 24 hours.
277
+ */
278
+ export async function editMessageFeishu(params: {
279
+ cfg: ClawdbotConfig;
280
+ messageId: string;
281
+ text: string;
282
+ }): Promise<void> {
283
+ const { cfg, messageId, text } = params;
284
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
285
+ if (!feishuCfg) {
286
+ throw new Error("Feishu channel not configured");
287
+ }
288
+
289
+ const client = createFeishuClient(feishuCfg);
290
+ const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
291
+ cfg,
292
+ channel: "feishu",
293
+ });
294
+ const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
295
+ const content = JSON.stringify({ text: messageText });
296
+
297
+ const response = await client.im.message.update({
298
+ path: { message_id: messageId },
299
+ data: {
300
+ msg_type: "text",
301
+ content,
302
+ },
303
+ });
304
+
305
+ if (response.code !== 0) {
306
+ throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
307
+ }
308
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { FeishuIdType } from "./types.js";
2
+
3
+ const CHAT_ID_PREFIX = "oc_";
4
+ const OPEN_ID_PREFIX = "ou_";
5
+ const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
6
+
7
+ export function detectIdType(id: string): FeishuIdType | null {
8
+ const trimmed = id.trim();
9
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
10
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
11
+ if (USER_ID_REGEX.test(trimmed)) return "user_id";
12
+ return null;
13
+ }
14
+
15
+ export function normalizeFeishuTarget(raw: string): string | null {
16
+ const trimmed = raw.trim();
17
+ if (!trimmed) return null;
18
+
19
+ const lowered = trimmed.toLowerCase();
20
+ if (lowered.startsWith("chat:")) {
21
+ return trimmed.slice("chat:".length).trim() || null;
22
+ }
23
+ if (lowered.startsWith("user:")) {
24
+ return trimmed.slice("user:".length).trim() || null;
25
+ }
26
+ if (lowered.startsWith("open_id:")) {
27
+ return trimmed.slice("open_id:".length).trim() || null;
28
+ }
29
+
30
+ return trimmed;
31
+ }
32
+
33
+ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
34
+ const trimmed = id.trim();
35
+ if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) {
36
+ return `chat:${trimmed}`;
37
+ }
38
+ if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) {
39
+ return `user:${trimmed}`;
40
+ }
41
+ return trimmed;
42
+ }
43
+
44
+ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
45
+ const trimmed = id.trim();
46
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
47
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
48
+ return "open_id";
49
+ }
50
+
51
+ export function looksLikeFeishuId(raw: string): boolean {
52
+ const trimmed = raw.trim();
53
+ if (!trimmed) return false;
54
+ if (/^(chat|user|open_id):/i.test(trimmed)) return true;
55
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) return true;
56
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) return true;
57
+ return false;
58
+ }
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js";
2
+
3
+ export type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
4
+ export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
5
+
6
+ export type FeishuDomain = "feishu" | "lark";
7
+ export type FeishuConnectionMode = "websocket" | "webhook";
8
+
9
+ export type ResolvedFeishuAccount = {
10
+ accountId: string;
11
+ enabled: boolean;
12
+ configured: boolean;
13
+ appId?: string;
14
+ domain: FeishuDomain;
15
+ };
16
+
17
+ export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id";
18
+
19
+ export type FeishuMessageContext = {
20
+ chatId: string;
21
+ messageId: string;
22
+ senderId: string;
23
+ senderOpenId: string;
24
+ senderName?: string;
25
+ chatType: "p2p" | "group";
26
+ mentionedBot: boolean;
27
+ rootId?: string;
28
+ parentId?: string;
29
+ content: string;
30
+ contentType: string;
31
+ };
32
+
33
+ export type FeishuSendResult = {
34
+ messageId: string;
35
+ chatId: string;
36
+ };
37
+
38
+ export type FeishuProbeResult = {
39
+ ok: boolean;
40
+ error?: string;
41
+ appId?: string;
42
+ botName?: string;
43
+ botOpenId?: string;
44
+ };
45
+
46
+ export type FeishuMediaInfo = {
47
+ path: string;
48
+ contentType?: string;
49
+ placeholder: string;
50
+ };
package/src/typing.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
2
+ import type { FeishuConfig } from "./types.js";
3
+ import { createFeishuClient } from "./client.js";
4
+
5
+ // Feishu emoji types for typing indicator
6
+ // See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
7
+ // Full list: https://github.com/go-lark/lark/blob/main/emoji.go
8
+ const TYPING_EMOJI = "Typing"; // Typing indicator emoji
9
+
10
+ export type TypingIndicatorState = {
11
+ messageId: string;
12
+ reactionId: string | null;
13
+ };
14
+
15
+ /**
16
+ * Add a typing indicator (reaction) to a message
17
+ */
18
+ export async function addTypingIndicator(params: {
19
+ cfg: ClawdbotConfig;
20
+ messageId: string;
21
+ }): Promise<TypingIndicatorState> {
22
+ const { cfg, messageId } = params;
23
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
24
+ if (!feishuCfg) {
25
+ return { messageId, reactionId: null };
26
+ }
27
+
28
+ const client = createFeishuClient(feishuCfg);
29
+
30
+ try {
31
+ const response = await client.im.messageReaction.create({
32
+ path: { message_id: messageId },
33
+ data: {
34
+ reaction_type: { emoji_type: TYPING_EMOJI },
35
+ },
36
+ });
37
+
38
+ const reactionId = (response as any)?.data?.reaction_id ?? null;
39
+ return { messageId, reactionId };
40
+ } catch (err) {
41
+ // Silently fail - typing indicator is not critical
42
+ console.log(`[feishu] failed to add typing indicator: ${err}`);
43
+ return { messageId, reactionId: null };
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Remove a typing indicator (reaction) from a message
49
+ */
50
+ export async function removeTypingIndicator(params: {
51
+ cfg: ClawdbotConfig;
52
+ state: TypingIndicatorState;
53
+ }): Promise<void> {
54
+ const { cfg, state } = params;
55
+ if (!state.reactionId) return;
56
+
57
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
58
+ if (!feishuCfg) return;
59
+
60
+ const client = createFeishuClient(feishuCfg);
61
+
62
+ try {
63
+ await client.im.messageReaction.delete({
64
+ path: {
65
+ message_id: state.messageId,
66
+ reaction_id: state.reactionId,
67
+ },
68
+ });
69
+ } catch (err) {
70
+ // Silently fail - cleanup is not critical
71
+ console.log(`[feishu] failed to remove typing indicator: ${err}`);
72
+ }
73
+ }