@coze-arch/cli 0.0.17 → 0.0.19-beta.1

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.
Files changed (104) hide show
  1. package/lib/__templates__/expo/.coze +1 -0
  2. package/lib/__templates__/expo/.cozeproj/scripts/validate.sh +8 -0
  3. package/lib/__templates__/expo/package.json +2 -1
  4. package/lib/__templates__/nextjs/.coze +1 -0
  5. package/lib/__templates__/nextjs/package.json +3 -1
  6. package/lib/__templates__/nextjs/scripts/validate.sh +10 -0
  7. package/lib/__templates__/nuxt-vue/.coze +1 -0
  8. package/lib/__templates__/nuxt-vue/eslint.config.mjs +25 -0
  9. package/lib/__templates__/nuxt-vue/package.json +9 -2
  10. package/lib/__templates__/nuxt-vue/pnpm-lock.yaml +790 -10
  11. package/lib/__templates__/nuxt-vue/scripts/validate.sh +10 -0
  12. package/lib/__templates__/pi-agent/.coze +10 -0
  13. package/lib/__templates__/pi-agent/AGENTS.md +144 -0
  14. package/lib/__templates__/pi-agent/README.md +216 -0
  15. package/lib/__templates__/pi-agent/_gitignore +3 -0
  16. package/lib/__templates__/pi-agent/_npmrc +23 -0
  17. package/lib/__templates__/pi-agent/bin/pi-bot.ts +8 -0
  18. package/lib/__templates__/pi-agent/docs/project-overview.md +374 -0
  19. package/lib/__templates__/pi-agent/docs/user/getting-started.md +47 -0
  20. package/lib/__templates__/pi-agent/package.json +63 -0
  21. package/lib/__templates__/pi-agent/pi-resources/SYSTEM.md +15 -0
  22. package/lib/__templates__/pi-agent/pi-resources/extensions/preference-memory/index.ts +355 -0
  23. package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/SKILL.md +36 -0
  24. package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/scripts/asr.mjs +9 -0
  25. package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/SKILL.md +41 -0
  26. package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/scripts/gen.mjs +9 -0
  27. package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/SKILL.md +85 -0
  28. package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/scripts/tts.mjs +9 -0
  29. package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/SKILL.md +53 -0
  30. package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/scripts/gen.mjs +9 -0
  31. package/lib/__templates__/pi-agent/pnpm-lock.yaml +8282 -0
  32. package/lib/__templates__/pi-agent/scripts/dev.sh +14 -0
  33. package/lib/__templates__/pi-agent/scripts/prepare.sh +35 -0
  34. package/lib/__templates__/pi-agent/src/agent.ts +363 -0
  35. package/lib/__templates__/pi-agent/src/channels/feishu/index.ts +760 -0
  36. package/lib/__templates__/pi-agent/src/channels/feishu/streaming-card.ts +297 -0
  37. package/lib/__templates__/pi-agent/src/channels/wechat/index.ts +171 -0
  38. package/lib/__templates__/pi-agent/src/cli.ts +117 -0
  39. package/lib/__templates__/pi-agent/src/config.ts +708 -0
  40. package/lib/__templates__/pi-agent/src/core.ts +218 -0
  41. package/lib/__templates__/pi-agent/src/dashboard/api/channels.ts +104 -0
  42. package/lib/__templates__/pi-agent/src/dashboard/api/docs.ts +204 -0
  43. package/lib/__templates__/pi-agent/src/dashboard/api/models.ts +98 -0
  44. package/lib/__templates__/pi-agent/src/dashboard/api/overview.ts +33 -0
  45. package/lib/__templates__/pi-agent/src/dashboard/config-store.ts +64 -0
  46. package/lib/__templates__/pi-agent/src/dashboard/index.ts +39 -0
  47. package/lib/__templates__/pi-agent/src/dashboard/server.ts +622 -0
  48. package/lib/__templates__/pi-agent/src/dashboard/types.ts +25 -0
  49. package/lib/__templates__/pi-agent/src/dashboard/web/index.html +13 -0
  50. package/lib/__templates__/pi-agent/src/dashboard/web/postcss.config.cjs +7 -0
  51. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/app-layout.tsx +186 -0
  52. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/page-title.tsx +17 -0
  53. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/alert.tsx +22 -0
  54. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/badge.tsx +25 -0
  55. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/button.tsx +40 -0
  56. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/card.tsx +29 -0
  57. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/input.tsx +18 -0
  58. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/label.tsx +8 -0
  59. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/select.tsx +80 -0
  60. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/separator.tsx +23 -0
  61. package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-fetch.ts +32 -0
  62. package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-local-storage-state.ts +23 -0
  63. package/lib/__templates__/pi-agent/src/dashboard/web/src/main.tsx +30 -0
  64. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/channels-page.tsx +188 -0
  65. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/chat-page.tsx +451 -0
  66. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/docs-page.tsx +65 -0
  67. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/models-page.tsx +122 -0
  68. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/overview-page.tsx +134 -0
  69. package/lib/__templates__/pi-agent/src/dashboard/web/src/services/chat-ws-service.ts +167 -0
  70. package/lib/__templates__/pi-agent/src/dashboard/web/src/styles.css +294 -0
  71. package/lib/__templates__/pi-agent/src/dashboard/web/src/utils/index.ts +11 -0
  72. package/lib/__templates__/pi-agent/src/dashboard/web/tsconfig.json +13 -0
  73. package/lib/__templates__/pi-agent/src/dashboard/web/vite.config.ts +17 -0
  74. package/lib/__templates__/pi-agent/src/index.ts +123 -0
  75. package/lib/__templates__/pi-agent/src/pi-resources.ts +125 -0
  76. package/lib/__templates__/pi-agent/src/session-store.ts +223 -0
  77. package/lib/__templates__/pi-agent/src/tools/common/format-coze-error.ts +12 -0
  78. package/lib/__templates__/pi-agent/src/tools/index.ts +2 -0
  79. package/lib/__templates__/pi-agent/src/tools/web-fetch/index.ts +195 -0
  80. package/lib/__templates__/pi-agent/src/tools/web-search/index.ts +206 -0
  81. package/lib/__templates__/pi-agent/template.config.js +45 -0
  82. package/lib/__templates__/pi-agent/tests/cli.test.ts +136 -0
  83. package/lib/__templates__/pi-agent/tests/config.test.ts +315 -0
  84. package/lib/__templates__/pi-agent/tests/dashboard-docs-api.test.ts +125 -0
  85. package/lib/__templates__/pi-agent/tests/dashboard-models-api.test.ts +171 -0
  86. package/lib/__templates__/pi-agent/tests/feishu-channel.test.ts +149 -0
  87. package/lib/__templates__/pi-agent/tests/feishu-streaming-card.test.ts +15 -0
  88. package/lib/__templates__/pi-agent/tests/pi-resources.test.ts +73 -0
  89. package/lib/__templates__/pi-agent/tests/preference-memory.test.ts +43 -0
  90. package/lib/__templates__/pi-agent/tests/session-store.test.ts +61 -0
  91. package/lib/__templates__/pi-agent/tests/smoke/run-smoke.ts +275 -0
  92. package/lib/__templates__/pi-agent/tests/web-fetch.test.ts +157 -0
  93. package/lib/__templates__/pi-agent/tests/web-search.test.ts +208 -0
  94. package/lib/__templates__/pi-agent/tsconfig.json +21 -0
  95. package/lib/__templates__/pi-agent/types/larksuiteoapi-node-sdk.d.ts +113 -0
  96. package/lib/__templates__/taro/.coze +1 -0
  97. package/lib/__templates__/taro/.cozeproj/scripts/validate.sh +8 -0
  98. package/lib/__templates__/taro/package.json +1 -1
  99. package/lib/__templates__/templates.json +18 -31
  100. package/lib/__templates__/vite/.coze +1 -0
  101. package/lib/__templates__/vite/package.json +3 -1
  102. package/lib/__templates__/vite/scripts/validate.sh +10 -0
  103. package/lib/cli.js +13 -2
  104. package/package.json +1 -1
@@ -0,0 +1,760 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ import {
3
+ type BotMessage,
4
+ type BotReply,
5
+ type ChannelHandler,
6
+ type ChannelInstance,
7
+ type ChannelTransport,
8
+ createBotMessage,
9
+ normalizeReply,
10
+ shouldHandleGroupMessage
11
+ } from "../../core.js";
12
+ import { FeishuStreamingCardSession } from "./streaming-card.js";
13
+
14
+ export interface FeishuMention {
15
+ id?: string;
16
+ name?: string;
17
+ key?: string;
18
+ open_id?: string;
19
+ user_id?: string;
20
+ union_id?: string;
21
+ id_container?: {
22
+ open_id?: string;
23
+ user_id?: string;
24
+ union_id?: string;
25
+ };
26
+ }
27
+
28
+ export interface FeishuMessagePayload {
29
+ message_id?: string;
30
+ chat_id?: string;
31
+ chat_type?: string;
32
+ content?: string;
33
+ thread_id?: string;
34
+ }
35
+
36
+ export interface FeishuSenderPayload {
37
+ sender_id?: {
38
+ open_id?: string;
39
+ user_id?: string;
40
+ union_id?: string;
41
+ };
42
+ id?: string;
43
+ }
44
+
45
+ export interface FeishuIncomingEvent {
46
+ event?: FeishuIncomingEvent;
47
+ message?: FeishuMessagePayload;
48
+ sender?: FeishuSenderPayload;
49
+ mentions?: FeishuMention[];
50
+ chatType?: string;
51
+ chatId?: string;
52
+ senderId?: string;
53
+ messageId?: string;
54
+ content?: string;
55
+ threadId?: string;
56
+ }
57
+
58
+ export interface FeishuOutgoingMessage extends BotReply {
59
+ channel: "feishu";
60
+ conversationId: string;
61
+ replyToMessageId: string;
62
+ }
63
+
64
+ export interface FeishuReactionHandle {
65
+ messageId: string;
66
+ reactionId: string;
67
+ emojiType: string;
68
+ }
69
+
70
+ export interface FeishuTransport extends ChannelTransport<FeishuOutgoingMessage> {
71
+ addReaction?(
72
+ reaction: Pick<FeishuReactionHandle, "messageId" | "emojiType">
73
+ ):
74
+ | Promise<Pick<FeishuReactionHandle, "reactionId" | "emojiType"> | null | void>
75
+ | Pick<FeishuReactionHandle, "reactionId" | "emojiType">
76
+ | null
77
+ | void;
78
+ removeReaction?(reaction: FeishuReactionHandle): Promise<void> | void;
79
+ }
80
+
81
+ export interface FeishuChannelConfig {
82
+ appId?: string;
83
+ appSecret?: string;
84
+ domain?: string;
85
+ encryptKey?: string;
86
+ verificationToken?: string;
87
+ debug?: boolean;
88
+ dedupeWindowMs?: number;
89
+ thinkingReaction?: {
90
+ enabled?: boolean;
91
+ emojiType?: string;
92
+ };
93
+ routing?: {
94
+ groupRequireMention?: boolean;
95
+ };
96
+ transport?: FeishuTransport;
97
+ }
98
+
99
+ export interface FeishuHandleResult {
100
+ handled: boolean;
101
+ reason?: "ignored" | "filtered";
102
+ message?: BotMessage;
103
+ reply?: FeishuOutgoingMessage | null;
104
+ }
105
+
106
+ export interface FeishuChannel extends ChannelInstance {
107
+ name: "feishu";
108
+ isStarted(): boolean;
109
+ getSentMessages(): FeishuOutgoingMessage[];
110
+ handleEvent(event: FeishuIncomingEvent): Promise<FeishuHandleResult>;
111
+ simulateIncomingText(input: {
112
+ text: string;
113
+ senderId?: string;
114
+ conversationId?: string;
115
+ isDirectMessage?: boolean;
116
+ mentions?: FeishuMention[];
117
+ }): Promise<FeishuHandleResult>;
118
+ }
119
+
120
+ const DEFAULT_THINKING_REACTION_EMOJI_TYPE = "OneSecond";
121
+
122
+ function parseTextContent(content: string | undefined): string {
123
+ if (!content) return "";
124
+
125
+ try {
126
+ const parsed = JSON.parse(content) as { text?: unknown };
127
+ if (typeof parsed.text === "string") {
128
+ return parsed.text;
129
+ }
130
+ } catch {
131
+ return content;
132
+ }
133
+
134
+ return content;
135
+ }
136
+
137
+ function unwrapSource(event: FeishuIncomingEvent): FeishuIncomingEvent {
138
+ return event.event ?? event;
139
+ }
140
+
141
+ function normalizeMentions(mentions: FeishuMention[] | undefined): string[] {
142
+ return (mentions ?? [])
143
+ .map(
144
+ (mention) =>
145
+ mention.name ??
146
+ mention.id ??
147
+ mention.key ??
148
+ mention.open_id ??
149
+ mention.id_container?.open_id ??
150
+ mention.user_id ??
151
+ mention.id_container?.user_id
152
+ )
153
+ .filter((value): value is string => Boolean(value));
154
+ }
155
+
156
+ function normalizeSenderId(source: FeishuIncomingEvent): string {
157
+ return (
158
+ source.sender?.sender_id?.open_id ??
159
+ source.sender?.sender_id?.user_id ??
160
+ source.sender?.sender_id?.union_id ??
161
+ source.sender?.id ??
162
+ source.senderId ??
163
+ "unknown-feishu-user"
164
+ );
165
+ }
166
+
167
+ function createSdkClient(config: FeishuChannelConfig): Lark.Client {
168
+ if (!config.appId || !config.appSecret) {
169
+ throw new Error("Missing Feishu appId/appSecret.");
170
+ }
171
+
172
+ return new Lark.Client({
173
+ appId: config.appId,
174
+ appSecret: config.appSecret,
175
+ appType: Lark.AppType.SelfBuild,
176
+ domain: (config.domain as Lark.Domain | undefined) ?? Lark.Domain.Feishu
177
+ });
178
+ }
179
+
180
+ export function normalizeFeishuEvent(event: FeishuIncomingEvent): BotMessage | null {
181
+ const source = unwrapSource(event);
182
+ const message = source.message;
183
+ if (!message) return null;
184
+
185
+ // This is the main platform boundary: after this point the app should only
186
+ // reason about BotMessage, not raw Feishu payloads.
187
+ const chatType = message.chat_type ?? source.chatType ?? "p2p";
188
+
189
+ return createBotMessage({
190
+ channel: "feishu",
191
+ conversationId: message.chat_id ?? source.chatId ?? "unknown-feishu-chat",
192
+ senderId: normalizeSenderId(source),
193
+ messageId: message.message_id ?? source.messageId ?? `feishu-${Date.now()}`,
194
+ text: parseTextContent(message.content ?? source.content),
195
+ isDirectMessage: chatType === "p2p",
196
+ mentions: normalizeMentions(source.mentions),
197
+ threadId: message.thread_id ?? source.threadId,
198
+ raw: event
199
+ });
200
+ }
201
+
202
+ function createOutgoingMessage(message: BotMessage, reply: BotReply): FeishuOutgoingMessage {
203
+ return {
204
+ channel: "feishu",
205
+ conversationId: message.conversationId,
206
+ replyToMessageId: message.messageId,
207
+ text: reply.text
208
+ };
209
+ }
210
+
211
+ function describeError(error: unknown): string {
212
+ if (error instanceof Error) {
213
+ return error.message;
214
+ }
215
+
216
+ return String(error);
217
+ }
218
+
219
+ export function createFeishuChannel(
220
+ config: FeishuChannelConfig = {},
221
+ handler: ChannelHandler = {}
222
+ ): FeishuChannel {
223
+ const sentMessages: FeishuOutgoingMessage[] = [];
224
+ const processedMessageIds = new Map<string, number>();
225
+ const inFlightMessageIds = new Set<string>();
226
+ let simulatedMessageCounter = 0;
227
+ const debugEnabled =
228
+ config.debug ??
229
+ (process.env.PI_BOT_DEBUG_FEISHU === "1" || process.env.PI_BOT_DEBUG_FEISHU === "true");
230
+ let started = false;
231
+ let botOpenId: string | undefined;
232
+ let wsClient: Lark.WSClient | undefined;
233
+ let sdkClient: Lark.Client | undefined;
234
+ let channel: FeishuChannel;
235
+ const dedupeWindowMs = config.dedupeWindowMs ?? 24 * 60 * 60 * 1000;
236
+ const thinkingReactionEnabled = config.thinkingReaction?.enabled ?? true;
237
+ const thinkingReactionEmojiType =
238
+ config.thinkingReaction?.emojiType ?? DEFAULT_THINKING_REACTION_EMOJI_TYPE;
239
+
240
+ function debugLog(message: string, fields?: Record<string, unknown>): void {
241
+ if (!debugEnabled) return;
242
+
243
+ const prefix = `[pi-bot][feishu][${new Date().toISOString()}] ${message}`;
244
+ if (!fields || Object.keys(fields).length === 0) {
245
+ console.log(prefix);
246
+ return;
247
+ }
248
+
249
+ console.log(prefix, fields);
250
+ }
251
+
252
+ function pruneProcessedMessageIds(now: number): void {
253
+ for (const [messageId, timestamp] of processedMessageIds.entries()) {
254
+ if (now - timestamp > dedupeWindowMs) {
255
+ processedMessageIds.delete(messageId);
256
+ }
257
+ }
258
+ }
259
+
260
+ /** Detect markdown elements that benefit from card rendering (same heuristic as openclaw). */
261
+ function shouldUseCard(text: string): boolean {
262
+ // Be slightly permissive: during streaming we may only see the opening fence.
263
+ return /```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
264
+ }
265
+
266
+ async function sendReply(
267
+ message: BotMessage,
268
+ reply: string | BotReply | null | void
269
+ ): Promise<FeishuOutgoingMessage | null> {
270
+ const normalizedReply = normalizeReply(reply);
271
+ if (!normalizedReply) return null;
272
+
273
+ const outgoingMessage = createOutgoingMessage(message, normalizedReply);
274
+ sentMessages.push(outgoingMessage);
275
+
276
+ // Tests and local simulations inject a transport. Real Feishu mode falls
277
+ // back to the SDK client below.
278
+ if (config.transport?.send) {
279
+ await config.transport.send(outgoingMessage);
280
+ } else if (sdkClient) {
281
+ const useCard = shouldUseCard(normalizedReply.text);
282
+ if (useCard) {
283
+ await sdkClient.im.message.reply({
284
+ path: {
285
+ message_id: message.messageId
286
+ },
287
+ data: {
288
+ msg_type: "interactive",
289
+ content: JSON.stringify({
290
+ config: {
291
+ wide_screen_mode: true
292
+ },
293
+ elements: [
294
+ {
295
+ tag: "markdown",
296
+ content: normalizedReply.text
297
+ }
298
+ ]
299
+ })
300
+ }
301
+ });
302
+ } else {
303
+ await sdkClient.im.message.reply({
304
+ path: {
305
+ message_id: message.messageId
306
+ },
307
+ data: {
308
+ msg_type: "text",
309
+ content: JSON.stringify({
310
+ text: normalizedReply.text
311
+ })
312
+ }
313
+ });
314
+ }
315
+ }
316
+
317
+ return outgoingMessage;
318
+ }
319
+
320
+ async function sendStreamingReply(message: BotMessage): Promise<FeishuOutgoingMessage | null> {
321
+ if (!handler.onStreamMessage || !sdkClient || !config.appId || !config.appSecret) {
322
+ return null;
323
+ }
324
+
325
+ const session = new FeishuStreamingCardSession(
326
+ sdkClient,
327
+ {
328
+ appId: config.appId,
329
+ appSecret: config.appSecret,
330
+ domain: config.domain
331
+ },
332
+ debugLog
333
+ );
334
+
335
+ let reply: string | BotReply | null | void = null;
336
+ let streamText = "";
337
+ let started = false;
338
+ let startPromise: Promise<void> | null = null;
339
+ let shouldStreamCard: boolean | null = null;
340
+
341
+ const ensureStarted = async () => {
342
+ if (started) return;
343
+ if (!startPromise) {
344
+ startPromise = (async () => {
345
+ try {
346
+ await session.start(message.conversationId, "chat_id", {
347
+ replyToMessageId: message.messageId,
348
+ replyInThread: Boolean(message.threadId)
349
+ });
350
+ started = true;
351
+ if (streamText) {
352
+ await session.update(streamText);
353
+ }
354
+ } catch (error) {
355
+ debugLog("failed to start streaming card, fallback to final reply", {
356
+ error: String(error),
357
+ messageId: message.messageId
358
+ });
359
+ }
360
+ })();
361
+ }
362
+ await startPromise;
363
+ };
364
+ try {
365
+ reply = await handler.onStreamMessage(message, {
366
+ onMeta(meta) {
367
+ debugLog("stream meta", {
368
+ sessionKey: meta.sessionKey,
369
+ messageId: message.messageId
370
+ });
371
+ },
372
+ onDelta(delta) {
373
+ // `agent.stream()` forwards real append-only text deltas once available.
374
+ // Concatenate here instead of fuzzy-merging, otherwise short chunks like
375
+ // "arr" may be dropped just because they already appeared earlier.
376
+ streamText += delta;
377
+ if (shouldStreamCard === null && shouldUseCard(streamText)) {
378
+ shouldStreamCard = true;
379
+ // Start a streaming card only for markdown-like content. Plain text should
380
+ // fall back to a normal `text` reply at the end.
381
+ void ensureStarted();
382
+ }
383
+ if (!started || !session.isActive()) return;
384
+ void session.update(streamText).catch((error) => {
385
+ debugLog("failed to update streaming card", {
386
+ error: String(error),
387
+ messageId: message.messageId
388
+ });
389
+ });
390
+ },
391
+ onError(error) {
392
+ debugLog("stream error", {
393
+ error,
394
+ messageId: message.messageId
395
+ });
396
+ }
397
+ });
398
+
399
+ const normalizedReply = normalizeReply(reply);
400
+ const finalText = normalizedReply?.text ?? "";
401
+ // Prefer the model's final text to avoid any merge artifacts from streaming.
402
+ const outputText = finalText || streamText;
403
+
404
+ // Decide card rendering based on the final output if we haven't decided yet.
405
+ if (shouldStreamCard === null) {
406
+ shouldStreamCard = shouldUseCard(outputText);
407
+ }
408
+ // If we need a card but haven't started yet (e.g. short replies), start now.
409
+ if (shouldStreamCard && !started) {
410
+ await ensureStarted();
411
+ }
412
+
413
+ if (started && session.isActive()) {
414
+ await session.close(outputText);
415
+ if (!normalizedReply) return null;
416
+ const outgoingMessage = createOutgoingMessage(message, { text: outputText });
417
+ sentMessages.push(outgoingMessage);
418
+ return outgoingMessage;
419
+ }
420
+
421
+ // No streaming-card started (or not needed): fall back to normal reply path.
422
+ if (!normalizedReply) {
423
+ return null;
424
+ }
425
+ return await sendReply(message, { text: outputText });
426
+ } catch (error) {
427
+ if (started && session.isActive()) {
428
+ try {
429
+ const finalText = normalizeReply(reply)?.text ?? "";
430
+ await session.close(finalText || streamText);
431
+ } catch (closeError) {
432
+ debugLog("failed to close streaming card after error", {
433
+ error: String(closeError),
434
+ messageId: message.messageId
435
+ });
436
+ }
437
+ }
438
+ throw error;
439
+ }
440
+ }
441
+
442
+ async function addThinkingReaction(message: BotMessage): Promise<FeishuReactionHandle | null> {
443
+ if (!thinkingReactionEnabled) return null;
444
+
445
+ try {
446
+ if (config.transport?.addReaction) {
447
+ const result = await config.transport.addReaction({
448
+ messageId: message.messageId,
449
+ emojiType: thinkingReactionEmojiType
450
+ });
451
+ if (!result?.reactionId) {
452
+ debugLog("thinking reaction transport returned no reaction id", {
453
+ messageId: message.messageId,
454
+ emojiType: thinkingReactionEmojiType
455
+ });
456
+ return null;
457
+ }
458
+
459
+ return {
460
+ messageId: message.messageId,
461
+ reactionId: result.reactionId,
462
+ emojiType: result.emojiType ?? thinkingReactionEmojiType
463
+ };
464
+ }
465
+
466
+ if (!sdkClient?.im.messageReaction?.create) {
467
+ debugLog("thinking reaction api unavailable on sdk client", {
468
+ messageId: message.messageId,
469
+ emojiType: thinkingReactionEmojiType
470
+ });
471
+ return null;
472
+ }
473
+
474
+ const created = await sdkClient.im.messageReaction.create({
475
+ path: {
476
+ message_id: message.messageId
477
+ },
478
+ data: {
479
+ reaction_type: {
480
+ emoji_type: thinkingReactionEmojiType
481
+ }
482
+ }
483
+ });
484
+
485
+ if (!created.data?.reaction_id) {
486
+ debugLog("thinking reaction create returned no reaction id", {
487
+ messageId: message.messageId,
488
+ emojiType: thinkingReactionEmojiType,
489
+ code: created.code,
490
+ msg: created.msg
491
+ });
492
+ return null;
493
+ }
494
+
495
+ debugLog("thinking reaction added", {
496
+ messageId: message.messageId,
497
+ reactionId: created.data.reaction_id,
498
+ emojiType: created.data.reaction_type?.emoji_type ?? thinkingReactionEmojiType
499
+ });
500
+
501
+ return {
502
+ messageId: message.messageId,
503
+ reactionId: created.data.reaction_id,
504
+ emojiType: created.data.reaction_type?.emoji_type ?? thinkingReactionEmojiType
505
+ };
506
+ } catch (error) {
507
+ debugLog("failed to add thinking reaction", {
508
+ messageId: message.messageId,
509
+ emojiType: thinkingReactionEmojiType,
510
+ error: describeError(error)
511
+ });
512
+ return null;
513
+ }
514
+ }
515
+
516
+ async function removeThinkingReaction(reaction: FeishuReactionHandle | null): Promise<void> {
517
+ if (!reaction) return;
518
+
519
+ try {
520
+ if (config.transport?.removeReaction) {
521
+ await config.transport.removeReaction(reaction);
522
+ return;
523
+ }
524
+
525
+ if (!sdkClient?.im.messageReaction?.delete) {
526
+ debugLog("thinking reaction delete api unavailable on sdk client", {
527
+ messageId: reaction.messageId,
528
+ reactionId: reaction.reactionId,
529
+ emojiType: reaction.emojiType
530
+ });
531
+ return;
532
+ }
533
+
534
+ const deleted = await sdkClient.im.messageReaction.delete({
535
+ path: {
536
+ message_id: reaction.messageId,
537
+ reaction_id: reaction.reactionId
538
+ }
539
+ });
540
+
541
+ debugLog("thinking reaction removed", {
542
+ messageId: reaction.messageId,
543
+ reactionId: reaction.reactionId,
544
+ emojiType: reaction.emojiType,
545
+ code: deleted.code,
546
+ msg: deleted.msg
547
+ });
548
+ } catch (error) {
549
+ debugLog("failed to remove thinking reaction", {
550
+ messageId: reaction.messageId,
551
+ reactionId: reaction.reactionId,
552
+ emojiType: reaction.emojiType,
553
+ error: describeError(error)
554
+ });
555
+ }
556
+ }
557
+
558
+ channel = {
559
+ name: "feishu",
560
+ async start() {
561
+ started = true;
562
+ await config.transport?.start?.();
563
+
564
+ // If appId/appSecret are present, this channel runs in real Feishu mode.
565
+ // Feishu pushes events over WS, so no public webhook URL is needed.
566
+ if (!config.transport && config.appId && config.appSecret) {
567
+ debugLog("starting real Feishu channel", {
568
+ domain: config.domain ?? Lark.Domain.Feishu,
569
+ dedupeWindowMs
570
+ });
571
+ sdkClient = createSdkClient(config);
572
+
573
+ const botInfo = await (sdkClient as unknown as {
574
+ request(args: { method: string; url: string; data: Record<string, never> }): Promise<{
575
+ bot?: { open_id?: string };
576
+ data?: { bot?: { open_id?: string } };
577
+ }>;
578
+ }).request({
579
+ method: "GET",
580
+ url: "/open-apis/bot/v3/info",
581
+ data: {}
582
+ });
583
+ botOpenId = botInfo?.bot?.open_id ?? botInfo?.data?.bot?.open_id;
584
+ debugLog("resolved bot identity", { botOpenId });
585
+
586
+ const dispatcher = new Lark.EventDispatcher({
587
+ encryptKey: config.encryptKey ?? "",
588
+ verificationToken: config.verificationToken ?? ""
589
+ });
590
+
591
+ dispatcher.register({
592
+ "im.message.receive_v1": async (data: unknown) => {
593
+ const normalized = normalizeFeishuEvent(data as FeishuIncomingEvent);
594
+ if (!normalized) return;
595
+ debugLog("received ws event", {
596
+ messageId: normalized.messageId,
597
+ senderId: normalized.senderId,
598
+ conversationId: normalized.conversationId,
599
+ isDirectMessage: normalized.isDirectMessage
600
+ });
601
+ // Ignore the bot's own messages to avoid reply loops.
602
+ if (botOpenId && normalized.senderId === botOpenId) {
603
+ debugLog("ignored bot self message from dispatcher", {
604
+ messageId: normalized.messageId,
605
+ senderId: normalized.senderId
606
+ });
607
+ return;
608
+ }
609
+ await channel.handleEvent(data as FeishuIncomingEvent);
610
+ }
611
+ } as never);
612
+
613
+ wsClient = new Lark.WSClient({
614
+ appId: config.appId,
615
+ appSecret: config.appSecret,
616
+ domain: (config.domain as Lark.Domain | undefined) ?? Lark.Domain.Feishu,
617
+ loggerLevel: Lark.LoggerLevel.warn
618
+ });
619
+
620
+ void wsClient.start({ eventDispatcher: dispatcher });
621
+ }
622
+ },
623
+ async stop() {
624
+ started = false;
625
+ try {
626
+ wsClient?.close({ force: true });
627
+ } catch {
628
+ // Ignore close errors during shutdown.
629
+ }
630
+ wsClient = undefined;
631
+ await config.transport?.stop?.();
632
+ },
633
+ isStarted() {
634
+ return started;
635
+ },
636
+ getSentMessages() {
637
+ return [...sentMessages];
638
+ },
639
+ async handleEvent(event) {
640
+ const message = normalizeFeishuEvent(event);
641
+ if (!message) {
642
+ debugLog("ignored event: normalization returned null");
643
+ return { handled: false, reason: "ignored" };
644
+ }
645
+
646
+ const now = Date.now();
647
+ pruneProcessedMessageIds(now);
648
+
649
+ if (botOpenId && message.senderId === botOpenId) {
650
+ debugLog("ignored bot self message", {
651
+ messageId: message.messageId,
652
+ senderId: message.senderId
653
+ });
654
+ return { handled: false, reason: "ignored" };
655
+ }
656
+ if (inFlightMessageIds.has(message.messageId)) {
657
+ debugLog("ignored duplicate in-flight message", {
658
+ messageId: message.messageId,
659
+ senderId: message.senderId,
660
+ conversationId: message.conversationId
661
+ });
662
+ return { handled: false, reason: "ignored", message };
663
+ }
664
+ if (processedMessageIds.has(message.messageId)) {
665
+ debugLog("ignored duplicate processed message", {
666
+ messageId: message.messageId,
667
+ senderId: message.senderId,
668
+ conversationId: message.conversationId,
669
+ firstProcessedAt: processedMessageIds.get(message.messageId)
670
+ });
671
+ return { handled: false, reason: "ignored", message };
672
+ }
673
+
674
+ // Group-message filtering happens here so the app runtime only sees
675
+ // messages that should actually trigger the bot.
676
+ const shouldHandle = shouldHandleGroupMessage(message, {
677
+ requireMention: config.routing?.groupRequireMention ?? true
678
+ });
679
+
680
+ if (!shouldHandle) {
681
+ debugLog("filtered group message", {
682
+ messageId: message.messageId,
683
+ senderId: message.senderId,
684
+ conversationId: message.conversationId,
685
+ mentions: message.mentions
686
+ });
687
+ return { handled: false, reason: "filtered", message };
688
+ }
689
+
690
+ inFlightMessageIds.add(message.messageId);
691
+ debugLog("handling message", {
692
+ messageId: message.messageId,
693
+ senderId: message.senderId,
694
+ conversationId: message.conversationId,
695
+ text: message.text
696
+ });
697
+
698
+ try {
699
+ const thinkingReaction = await addThinkingReaction(message);
700
+ let reply: string | BotReply | null | void = null;
701
+ let outgoingMessage: FeishuOutgoingMessage | null = null;
702
+ try {
703
+ if (handler.onStreamMessage && sdkClient && config.appId && config.appSecret) {
704
+ outgoingMessage = await sendStreamingReply(message);
705
+ } else {
706
+ reply = await handler.onMessage?.(message);
707
+ }
708
+ } finally {
709
+ await removeThinkingReaction(thinkingReaction);
710
+ }
711
+
712
+ if (!outgoingMessage) {
713
+ outgoingMessage = await sendReply(message, reply);
714
+ }
715
+
716
+ const processedAt = Date.now();
717
+ processedMessageIds.set(message.messageId, processedAt);
718
+ debugLog("message handled", {
719
+ messageId: message.messageId,
720
+ processedAt,
721
+ replied: Boolean(outgoingMessage),
722
+ replyLength: outgoingMessage?.text.length ?? 0
723
+ });
724
+
725
+ return {
726
+ handled: true,
727
+ message,
728
+ reply: outgoingMessage
729
+ };
730
+ } finally {
731
+ inFlightMessageIds.delete(message.messageId);
732
+ }
733
+ },
734
+ async simulateIncomingText({
735
+ text,
736
+ senderId = "feishu-user",
737
+ conversationId = "feishu-chat",
738
+ isDirectMessage = true,
739
+ mentions = []
740
+ }) {
741
+ simulatedMessageCounter += 1;
742
+ return this.handleEvent({
743
+ message: {
744
+ message_id: `feishu-sim-${Date.now()}-${simulatedMessageCounter}`,
745
+ chat_id: conversationId,
746
+ chat_type: isDirectMessage ? "p2p" : "group",
747
+ content: JSON.stringify({ text })
748
+ },
749
+ sender: {
750
+ sender_id: {
751
+ open_id: senderId
752
+ }
753
+ },
754
+ mentions
755
+ });
756
+ }
757
+ };
758
+
759
+ return channel;
760
+ }