@coze-arch/cli 0.0.18 → 0.0.19-alpha.502ddf
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/lib/__templates__/expo/.coze +1 -0
- package/lib/__templates__/expo/.cozeproj/scripts/validate.sh +8 -0
- package/lib/__templates__/expo/package.json +2 -1
- package/lib/__templates__/nextjs/.coze +1 -0
- package/lib/__templates__/nextjs/package.json +3 -1
- package/lib/__templates__/nextjs/scripts/validate.sh +10 -0
- package/lib/__templates__/nuxt-vue/.coze +1 -0
- package/lib/__templates__/nuxt-vue/app/pages/index.vue +6 -0
- package/lib/__templates__/nuxt-vue/eslint.config.mjs +25 -0
- package/lib/__templates__/nuxt-vue/nuxt.config.ts +2 -2
- package/lib/__templates__/nuxt-vue/package.json +9 -2
- package/lib/__templates__/nuxt-vue/pnpm-lock.yaml +790 -10
- package/lib/__templates__/nuxt-vue/scripts/validate.sh +10 -0
- package/lib/__templates__/pi-agent/.coze +10 -0
- package/lib/__templates__/pi-agent/AGENTS.md +149 -0
- package/lib/__templates__/pi-agent/README.md +218 -0
- package/lib/__templates__/pi-agent/_gitignore +3 -0
- package/lib/__templates__/pi-agent/_npmrc +23 -0
- package/lib/__templates__/pi-agent/bin/pi-bot.ts +8 -0
- package/lib/__templates__/pi-agent/docs/project-overview.md +368 -0
- package/lib/__templates__/pi-agent/docs/user/getting-started.md +46 -0
- package/lib/__templates__/pi-agent/package.json +63 -0
- package/lib/__templates__/pi-agent/pi-resources/SYSTEM.md +15 -0
- package/lib/__templates__/pi-agent/pi-resources/extensions/preference-memory/index.ts +355 -0
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/SKILL.md +30 -0
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/SKILL.md +29 -0
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/SKILL.md +57 -0
- package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/SKILL.md +40 -0
- package/lib/__templates__/pi-agent/pnpm-lock.yaml +8282 -0
- package/lib/__templates__/pi-agent/scripts/dev.sh +14 -0
- package/lib/__templates__/pi-agent/scripts/prepare.sh +35 -0
- package/lib/__templates__/pi-agent/src/agent.ts +363 -0
- package/lib/__templates__/pi-agent/src/channels/feishu/index.ts +760 -0
- package/lib/__templates__/pi-agent/src/channels/feishu/streaming-card.ts +297 -0
- package/lib/__templates__/pi-agent/src/channels/wechat/index.ts +171 -0
- package/lib/__templates__/pi-agent/src/cli.ts +117 -0
- package/lib/__templates__/pi-agent/src/config.ts +749 -0
- package/lib/__templates__/pi-agent/src/core.ts +219 -0
- package/lib/__templates__/pi-agent/src/dashboard/api/channels.ts +104 -0
- package/lib/__templates__/pi-agent/src/dashboard/api/models.ts +98 -0
- package/lib/__templates__/pi-agent/src/dashboard/api/overview.ts +33 -0
- package/lib/__templates__/pi-agent/src/dashboard/config-store.ts +64 -0
- package/lib/__templates__/pi-agent/src/dashboard/index.ts +74 -0
- package/lib/__templates__/pi-agent/src/dashboard/server.ts +610 -0
- package/lib/__templates__/pi-agent/src/dashboard/types.ts +25 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/index.html +13 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/postcss.config.cjs +7 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/app-layout.tsx +172 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/page-title.tsx +17 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/alert.tsx +22 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/badge.tsx +25 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/button.tsx +40 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/card.tsx +29 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/input.tsx +18 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/label.tsx +8 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/select.tsx +80 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/separator.tsx +23 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-fetch.ts +32 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-local-storage-state.ts +23 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/main.tsx +24 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/chat-page.tsx +440 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/overview-page.tsx +330 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/services/chat-ws-service.ts +167 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/styles.css +203 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/src/utils/index.ts +11 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/tsconfig.json +13 -0
- package/lib/__templates__/pi-agent/src/dashboard/web/vite.config.ts +17 -0
- package/lib/__templates__/pi-agent/src/index.ts +123 -0
- package/lib/__templates__/pi-agent/src/pi-resources.ts +125 -0
- package/lib/__templates__/pi-agent/src/session-store.ts +223 -0
- package/lib/__templates__/pi-agent/src/tools/common/format-coze-error.ts +12 -0
- package/lib/__templates__/pi-agent/src/tools/index.ts +2 -0
- package/lib/__templates__/pi-agent/src/tools/web-fetch/index.ts +195 -0
- package/lib/__templates__/pi-agent/src/tools/web-search/index.ts +206 -0
- package/lib/__templates__/pi-agent/template.config.js +45 -0
- package/lib/__templates__/pi-agent/tests/cli.test.ts +136 -0
- package/lib/__templates__/pi-agent/tests/config.test.ts +377 -0
- package/lib/__templates__/pi-agent/tests/dashboard-models-api.test.ts +171 -0
- package/lib/__templates__/pi-agent/tests/feishu-channel.test.ts +149 -0
- package/lib/__templates__/pi-agent/tests/feishu-streaming-card.test.ts +15 -0
- package/lib/__templates__/pi-agent/tests/pi-resources.test.ts +73 -0
- package/lib/__templates__/pi-agent/tests/preference-memory.test.ts +43 -0
- package/lib/__templates__/pi-agent/tests/session-store.test.ts +61 -0
- package/lib/__templates__/pi-agent/tests/smoke/run-smoke.ts +275 -0
- package/lib/__templates__/pi-agent/tests/web-fetch.test.ts +157 -0
- package/lib/__templates__/pi-agent/tests/web-search.test.ts +208 -0
- package/lib/__templates__/pi-agent/tsconfig.json +21 -0
- package/lib/__templates__/pi-agent/types/larksuiteoapi-node-sdk.d.ts +113 -0
- package/lib/__templates__/taro/.coze +1 -0
- package/lib/__templates__/taro/.cozeproj/scripts/validate.sh +8 -0
- package/lib/__templates__/taro/package.json +1 -1
- package/lib/__templates__/templates.json +24 -0
- package/lib/__templates__/vite/.coze +1 -0
- package/lib/__templates__/vite/package.json +3 -1
- package/lib/__templates__/vite/scripts/validate.sh +10 -0
- package/lib/cli.js +13 -2
- 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
|
+
}
|