@gakr-gakr/feishu 0.1.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/api.ts +32 -0
- package/autobot.plugin.json +180 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +82 -0
- package/package.json +62 -0
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.ts +13 -0
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +113 -0
- package/src/accounts.ts +333 -0
- package/src/agent-config.ts +21 -0
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.ts +104 -0
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.ts +762 -0
- package/src/bot-content.ts +485 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.ts +1703 -0
- package/src/card-action.ts +447 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +54 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.ts +1423 -0
- package/src/chat-schema.ts +25 -0
- package/src/chat.ts +188 -0
- package/src/client-timeout.ts +42 -0
- package/src/client.ts +262 -0
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.ts +303 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.ts +406 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.ts +335 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +141 -0
- package/src/dedupe-key.ts +72 -0
- package/src/directory.static.ts +61 -0
- package/src/directory.ts +124 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.ts +223 -0
- package/src/docx-color-text.ts +154 -0
- package/src/docx-table-ops.ts +316 -0
- package/src/docx-types.ts +38 -0
- package/src/docx.ts +1596 -0
- package/src/drive-schema.ts +92 -0
- package/src/drive.ts +829 -0
- package/src/dynamic-agent.ts +143 -0
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +19 -0
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.ts +1105 -0
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +114 -0
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +10 -0
- package/src/monitor.account.ts +492 -0
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +421 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.message-handler.ts +350 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.startup.ts +74 -0
- package/src/monitor.state.ts +170 -0
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +46 -0
- package/src/monitor.transport.ts +451 -0
- package/src/monitor.ts +100 -0
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.ts +785 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +170 -0
- package/src/pins.ts +108 -0
- package/src/policy.ts +321 -0
- package/src/post.ts +275 -0
- package/src/probe.ts +166 -0
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +123 -0
- package/src/reasoning-preview.ts +28 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.ts +748 -0
- package/src/runtime.ts +9 -0
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -0
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +80 -0
- package/src/send-target.ts +35 -0
- package/src/send.ts +861 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.ts +86 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/setup-surface.ts +618 -0
- package/src/streaming-card.ts +571 -0
- package/src/subagent-hooks.ts +413 -0
- package/src/targets.ts +97 -0
- package/src/thread-bindings.ts +331 -0
- package/src/tool-account.ts +93 -0
- package/src/tool-factory-test-harness.ts +79 -0
- package/src/tool-result.ts +16 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +106 -0
- package/src/typing.ts +214 -0
- package/src/wiki-schema.ts +69 -0
- package/src/wiki.ts +270 -0
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu Streaming Card - Card Kit streaming API for real-time text output
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Client } from "@larksuiteoapi/node-sdk";
|
|
6
|
+
import { fetchWithSsrFGuard } from "autobot/plugin-sdk/ssrf-runtime";
|
|
7
|
+
import { getFeishuUserAgent } from "./client.js";
|
|
8
|
+
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
|
|
9
|
+
import type { FeishuDomain } from "./types.js";
|
|
10
|
+
|
|
11
|
+
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
|
12
|
+
type CardState = {
|
|
13
|
+
cardId: string;
|
|
14
|
+
messageId: string;
|
|
15
|
+
sequence: number;
|
|
16
|
+
currentText: string;
|
|
17
|
+
sentText: string;
|
|
18
|
+
hasNote: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Options for customising the initial streaming card appearance. */
|
|
22
|
+
type StreamingCardOptions = {
|
|
23
|
+
/** Optional header with title and color template. */
|
|
24
|
+
header?: CardHeaderConfig;
|
|
25
|
+
/** Optional grey note footer text. */
|
|
26
|
+
note?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Optional header for streaming cards (title bar with color template) */
|
|
30
|
+
type StreamingCardHeader = {
|
|
31
|
+
title: string;
|
|
32
|
+
/** Color template: blue, green, red, orange, purple, indigo, wathet, turquoise, yellow, grey, carmine, violet, lime */
|
|
33
|
+
template?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type StreamingStartOptions = {
|
|
37
|
+
replyToMessageId?: string;
|
|
38
|
+
replyInThread?: boolean;
|
|
39
|
+
rootId?: string;
|
|
40
|
+
header?: StreamingCardHeader;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const STREAMING_UPDATE_THROTTLE_MS = 160;
|
|
44
|
+
const STREAMING_SIGNIFICANT_DELTA_CHARS = 18;
|
|
45
|
+
|
|
46
|
+
// Token cache (keyed by domain + appId)
|
|
47
|
+
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
48
|
+
|
|
49
|
+
function resolveApiBase(domain?: FeishuDomain): string {
|
|
50
|
+
if (domain === "lark") {
|
|
51
|
+
return "https://open.larksuite.com/open-apis";
|
|
52
|
+
}
|
|
53
|
+
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
54
|
+
return `${domain.replace(/\/+$/, "")}/open-apis`;
|
|
55
|
+
}
|
|
56
|
+
return "https://open.feishu.cn/open-apis";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
|
60
|
+
if (domain === "lark") {
|
|
61
|
+
return ["open.larksuite.com"];
|
|
62
|
+
}
|
|
63
|
+
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
64
|
+
try {
|
|
65
|
+
return [new URL(domain).hostname];
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return ["open.feishu.cn"];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function getToken(creds: Credentials): Promise<string> {
|
|
74
|
+
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
|
75
|
+
const cached = tokenCache.get(key);
|
|
76
|
+
if (cached && cached.expiresAt > Date.now() + 60000) {
|
|
77
|
+
return cached.token;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
81
|
+
url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
|
|
82
|
+
init: {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json", "User-Agent": getFeishuUserAgent() },
|
|
85
|
+
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
|
86
|
+
},
|
|
87
|
+
policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
|
|
88
|
+
auditContext: "feishu.streaming-card.token",
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
await release();
|
|
92
|
+
throw new Error(`Token request failed with HTTP ${response.status}`);
|
|
93
|
+
}
|
|
94
|
+
const data = (await response.json()) as {
|
|
95
|
+
code: number;
|
|
96
|
+
msg: string;
|
|
97
|
+
tenant_access_token?: string;
|
|
98
|
+
expire?: number;
|
|
99
|
+
};
|
|
100
|
+
await release();
|
|
101
|
+
if (data.code !== 0 || !data.tenant_access_token) {
|
|
102
|
+
throw new Error(`Token error: ${data.msg}`);
|
|
103
|
+
}
|
|
104
|
+
tokenCache.set(key, {
|
|
105
|
+
token: data.tenant_access_token,
|
|
106
|
+
expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
|
|
107
|
+
});
|
|
108
|
+
return data.tenant_access_token;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function truncateSummary(text: string, max = 50): string {
|
|
112
|
+
if (!text) {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
const clean = text.replace(/\n/g, " ").trim();
|
|
116
|
+
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasNaturalStreamingBoundary(text: string): boolean {
|
|
120
|
+
return /[\n。!?!?;;::]$/.test(text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function shouldPushStreamingUpdate(previousText: string, nextText: string): boolean {
|
|
124
|
+
if (!previousText) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (hasNaturalStreamingBoundary(nextText)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
return nextText.length - previousText.length >= STREAMING_SIGNIFICANT_DELTA_CHARS;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveStreamingCardAppendContent(previousText: string, nextText: string): string {
|
|
134
|
+
if (!nextText || nextText === previousText) {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
if (!previousText) {
|
|
138
|
+
return nextText;
|
|
139
|
+
}
|
|
140
|
+
return nextText.startsWith(previousText) ? nextText.slice(previousText.length) : nextText;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function mergeStreamingText(
|
|
144
|
+
previousText: string | undefined,
|
|
145
|
+
nextText: string | undefined,
|
|
146
|
+
): string {
|
|
147
|
+
const previous = typeof previousText === "string" ? previousText : "";
|
|
148
|
+
const next = typeof nextText === "string" ? nextText : "";
|
|
149
|
+
if (!next) {
|
|
150
|
+
return previous;
|
|
151
|
+
}
|
|
152
|
+
if (!previous || next === previous) {
|
|
153
|
+
return next;
|
|
154
|
+
}
|
|
155
|
+
if (next.startsWith(previous)) {
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
if (previous.startsWith(next)) {
|
|
159
|
+
return previous;
|
|
160
|
+
}
|
|
161
|
+
if (next.includes(previous)) {
|
|
162
|
+
return next;
|
|
163
|
+
}
|
|
164
|
+
if (previous.includes(next)) {
|
|
165
|
+
return previous;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Merge partial overlaps, e.g. "这" + "这是" => "这是".
|
|
169
|
+
const maxOverlap = Math.min(previous.length, next.length);
|
|
170
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
171
|
+
if (previous.slice(-overlap) === next.slice(0, overlap)) {
|
|
172
|
+
return `${previous}${next.slice(overlap)}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Fallback for fragmented partial chunks: append as-is to avoid losing tokens.
|
|
176
|
+
return `${previous}${next}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function resolveStreamingCardSendMode(options?: StreamingStartOptions) {
|
|
180
|
+
if (options?.replyToMessageId) {
|
|
181
|
+
return "reply";
|
|
182
|
+
}
|
|
183
|
+
if (options?.rootId) {
|
|
184
|
+
return "root_create";
|
|
185
|
+
}
|
|
186
|
+
return "create";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Streaming card session manager */
|
|
190
|
+
export class FeishuStreamingSession {
|
|
191
|
+
private client: Client;
|
|
192
|
+
private creds: Credentials;
|
|
193
|
+
private state: CardState | null = null;
|
|
194
|
+
private queue: Promise<void> = Promise.resolve();
|
|
195
|
+
private closed = false;
|
|
196
|
+
private log?: (msg: string) => void;
|
|
197
|
+
private lastUpdateTime = 0;
|
|
198
|
+
private pendingText: string | null = null;
|
|
199
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
200
|
+
private updateThrottleMs = STREAMING_UPDATE_THROTTLE_MS;
|
|
201
|
+
|
|
202
|
+
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
|
|
203
|
+
this.client = client;
|
|
204
|
+
this.creds = creds;
|
|
205
|
+
this.log = log;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async start(
|
|
209
|
+
receiveId: string,
|
|
210
|
+
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
|
211
|
+
options?: StreamingCardOptions & StreamingStartOptions,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
if (this.state) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
218
|
+
const elements: Record<string, unknown>[] = [
|
|
219
|
+
{ tag: "markdown", content: "", element_id: "content" },
|
|
220
|
+
];
|
|
221
|
+
if (options?.note) {
|
|
222
|
+
elements.push({ tag: "hr" });
|
|
223
|
+
elements.push({
|
|
224
|
+
tag: "markdown",
|
|
225
|
+
content: `<font color='grey'>${options.note}</font>`,
|
|
226
|
+
element_id: "note",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const cardJson: Record<string, unknown> = {
|
|
230
|
+
schema: "2.0",
|
|
231
|
+
config: {
|
|
232
|
+
streaming_mode: true,
|
|
233
|
+
summary: { content: "[Generating...]" },
|
|
234
|
+
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
|
|
235
|
+
},
|
|
236
|
+
body: { elements },
|
|
237
|
+
};
|
|
238
|
+
if (options?.header) {
|
|
239
|
+
cardJson.header = {
|
|
240
|
+
title: { tag: "plain_text", content: options.header.title },
|
|
241
|
+
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Create card entity
|
|
246
|
+
const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({
|
|
247
|
+
url: `${apiBase}/cardkit/v1/cards`,
|
|
248
|
+
init: {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: {
|
|
251
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
"User-Agent": getFeishuUserAgent(),
|
|
254
|
+
},
|
|
255
|
+
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
|
256
|
+
},
|
|
257
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
258
|
+
auditContext: "feishu.streaming-card.create",
|
|
259
|
+
});
|
|
260
|
+
if (!createRes.ok) {
|
|
261
|
+
await releaseCreate();
|
|
262
|
+
throw new Error(`Create card request failed with HTTP ${createRes.status}`);
|
|
263
|
+
}
|
|
264
|
+
const createData = (await createRes.json()) as {
|
|
265
|
+
code: number;
|
|
266
|
+
msg: string;
|
|
267
|
+
data?: { card_id: string };
|
|
268
|
+
};
|
|
269
|
+
await releaseCreate();
|
|
270
|
+
if (createData.code !== 0 || !createData.data?.card_id) {
|
|
271
|
+
throw new Error(`Create card failed: ${createData.msg}`);
|
|
272
|
+
}
|
|
273
|
+
const cardId = createData.data.card_id;
|
|
274
|
+
const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
|
|
275
|
+
|
|
276
|
+
// Prefer message.reply when we have a reply target — reply_in_thread
|
|
277
|
+
// reliably routes streaming cards into Feishu topics, whereas
|
|
278
|
+
// message.create with root_id may silently ignore root_id for card
|
|
279
|
+
// references (card_id format).
|
|
280
|
+
let sendRes;
|
|
281
|
+
const sendOptions = options ?? {};
|
|
282
|
+
const sendMode = resolveStreamingCardSendMode(sendOptions);
|
|
283
|
+
if (sendMode === "reply") {
|
|
284
|
+
sendRes = await this.client.im.message.reply({
|
|
285
|
+
path: { message_id: sendOptions.replyToMessageId! },
|
|
286
|
+
data: {
|
|
287
|
+
msg_type: "interactive",
|
|
288
|
+
content: cardContent,
|
|
289
|
+
...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
} else if (sendMode === "root_create") {
|
|
293
|
+
// root_id is undeclared in the SDK types but accepted at runtime
|
|
294
|
+
sendRes = await this.client.im.message.create({
|
|
295
|
+
params: { receive_id_type: receiveIdType },
|
|
296
|
+
data: Object.assign(
|
|
297
|
+
{ receive_id: receiveId, msg_type: "interactive", content: cardContent },
|
|
298
|
+
{ root_id: sendOptions.rootId },
|
|
299
|
+
),
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
sendRes = await this.client.im.message.create({
|
|
303
|
+
params: { receive_id_type: receiveIdType },
|
|
304
|
+
data: {
|
|
305
|
+
receive_id: receiveId,
|
|
306
|
+
msg_type: "interactive",
|
|
307
|
+
content: cardContent,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
|
312
|
+
throw new Error(`Send card failed: ${sendRes.msg}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
this.state = {
|
|
316
|
+
cardId,
|
|
317
|
+
messageId: sendRes.data.message_id,
|
|
318
|
+
sequence: 1,
|
|
319
|
+
currentText: "",
|
|
320
|
+
sentText: "",
|
|
321
|
+
hasNote: !!options?.note,
|
|
322
|
+
};
|
|
323
|
+
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async updateCardContent(
|
|
327
|
+
text: string,
|
|
328
|
+
onError?: (error: unknown) => void,
|
|
329
|
+
): Promise<boolean> {
|
|
330
|
+
if (!this.state) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
334
|
+
this.state.sequence += 1;
|
|
335
|
+
try {
|
|
336
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
337
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
|
|
338
|
+
init: {
|
|
339
|
+
method: "PUT",
|
|
340
|
+
headers: {
|
|
341
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
342
|
+
"Content-Type": "application/json",
|
|
343
|
+
"User-Agent": getFeishuUserAgent(),
|
|
344
|
+
},
|
|
345
|
+
body: JSON.stringify({
|
|
346
|
+
content: text,
|
|
347
|
+
sequence: this.state.sequence,
|
|
348
|
+
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
349
|
+
}),
|
|
350
|
+
},
|
|
351
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
352
|
+
auditContext: "feishu.streaming-card.update",
|
|
353
|
+
});
|
|
354
|
+
await release();
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
onError?.(new Error(`Update card content failed with HTTP ${response.status}`));
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
onError?.(error);
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private async replaceCardContent(
|
|
367
|
+
text: string,
|
|
368
|
+
onError?: (error: unknown) => void,
|
|
369
|
+
): Promise<boolean> {
|
|
370
|
+
if (!this.state) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
374
|
+
this.state.sequence += 1;
|
|
375
|
+
try {
|
|
376
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
377
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content`,
|
|
378
|
+
init: {
|
|
379
|
+
method: "PUT",
|
|
380
|
+
headers: {
|
|
381
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
382
|
+
"Content-Type": "application/json",
|
|
383
|
+
"User-Agent": getFeishuUserAgent(),
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
element: JSON.stringify({ tag: "markdown", content: text, element_id: "content" }),
|
|
387
|
+
sequence: this.state.sequence,
|
|
388
|
+
uuid: `r_${this.state.cardId}_${this.state.sequence}`,
|
|
389
|
+
}),
|
|
390
|
+
},
|
|
391
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
392
|
+
auditContext: "feishu.streaming-card.replace",
|
|
393
|
+
});
|
|
394
|
+
await release();
|
|
395
|
+
if (!response.ok) {
|
|
396
|
+
onError?.(new Error(`Replace card content failed with HTTP ${response.status}`));
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
return true;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
onError?.(error);
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private clearFlushTimer(): void {
|
|
407
|
+
if (this.flushTimer) {
|
|
408
|
+
clearTimeout(this.flushTimer);
|
|
409
|
+
this.flushTimer = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private schedulePendingFlush(): void {
|
|
414
|
+
if (this.flushTimer || !this.pendingText || this.closed) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const delayMs = Math.max(0, this.updateThrottleMs - (Date.now() - this.lastUpdateTime));
|
|
418
|
+
this.flushTimer = setTimeout(() => {
|
|
419
|
+
this.flushTimer = null;
|
|
420
|
+
const pending = this.pendingText;
|
|
421
|
+
if (!pending || this.closed) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
void this.update(pending);
|
|
425
|
+
}, delayMs);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async update(text: string): Promise<void> {
|
|
429
|
+
if (!this.state || this.closed) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
|
|
433
|
+
if (!mergedInput || mergedInput === this.state.currentText) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.pendingText = mergedInput;
|
|
437
|
+
this.clearFlushTimer();
|
|
438
|
+
|
|
439
|
+
const shouldForceUpdate = shouldPushStreamingUpdate(this.state.currentText, mergedInput);
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
if (!shouldForceUpdate && now - this.lastUpdateTime < this.updateThrottleMs) {
|
|
442
|
+
this.schedulePendingFlush();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.lastUpdateTime = now;
|
|
446
|
+
|
|
447
|
+
this.queue = this.queue.then(async () => {
|
|
448
|
+
if (!this.state || this.closed) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const nextText = this.pendingText ?? mergedInput;
|
|
452
|
+
const mergedText = mergeStreamingText(this.state.currentText, nextText);
|
|
453
|
+
if (!mergedText || mergedText === this.state.currentText) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const appendContent = resolveStreamingCardAppendContent(this.state.sentText, mergedText);
|
|
457
|
+
if (!appendContent) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.pendingText = null;
|
|
461
|
+
this.state.currentText = mergedText;
|
|
462
|
+
const sent = await this.updateCardContent(appendContent, (e) =>
|
|
463
|
+
this.log?.(`Update failed: ${String(e)}`),
|
|
464
|
+
);
|
|
465
|
+
if (sent && this.state) {
|
|
466
|
+
this.state.sentText = mergedText;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
await this.queue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async updateNoteContent(note: string): Promise<void> {
|
|
473
|
+
if (!this.state || !this.state.hasNote) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
477
|
+
this.state.sequence += 1;
|
|
478
|
+
await fetchWithSsrFGuard({
|
|
479
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`,
|
|
480
|
+
init: {
|
|
481
|
+
method: "PUT",
|
|
482
|
+
headers: {
|
|
483
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
484
|
+
"Content-Type": "application/json",
|
|
485
|
+
"User-Agent": getFeishuUserAgent(),
|
|
486
|
+
},
|
|
487
|
+
body: JSON.stringify({
|
|
488
|
+
content: `<font color='grey'>${note}</font>`,
|
|
489
|
+
sequence: this.state.sequence,
|
|
490
|
+
uuid: `n_${this.state.cardId}_${this.state.sequence}`,
|
|
491
|
+
}),
|
|
492
|
+
},
|
|
493
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
494
|
+
auditContext: "feishu.streaming-card.note-update",
|
|
495
|
+
})
|
|
496
|
+
.then(async ({ release }) => {
|
|
497
|
+
await release();
|
|
498
|
+
})
|
|
499
|
+
.catch((e) => this.log?.(`Note update failed: ${String(e)}`));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async close(finalText?: string, options?: { note?: string }): Promise<void> {
|
|
503
|
+
if (!this.state || this.closed) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
this.closed = true;
|
|
507
|
+
this.clearFlushTimer();
|
|
508
|
+
await this.queue;
|
|
509
|
+
|
|
510
|
+
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
|
|
511
|
+
const text = finalText ?? pendingMerged;
|
|
512
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
513
|
+
|
|
514
|
+
// Only send final update if content differs from what's already displayed
|
|
515
|
+
if (text && text !== this.state.sentText) {
|
|
516
|
+
const sent = text.startsWith(this.state.sentText)
|
|
517
|
+
? await this.updateCardContent(
|
|
518
|
+
resolveStreamingCardAppendContent(this.state.sentText, text),
|
|
519
|
+
(e) => this.log?.(`Final update failed: ${String(e)}`),
|
|
520
|
+
)
|
|
521
|
+
: await this.replaceCardContent(text, (e) =>
|
|
522
|
+
this.log?.(`Final replace failed: ${String(e)}`),
|
|
523
|
+
);
|
|
524
|
+
this.state.currentText = text;
|
|
525
|
+
if (sent) {
|
|
526
|
+
this.state.sentText = text;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Update note with final model/provider info
|
|
531
|
+
if (options?.note) {
|
|
532
|
+
await this.updateNoteContent(options.note);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Close streaming mode
|
|
536
|
+
this.state.sequence += 1;
|
|
537
|
+
await fetchWithSsrFGuard({
|
|
538
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`,
|
|
539
|
+
init: {
|
|
540
|
+
method: "PATCH",
|
|
541
|
+
headers: {
|
|
542
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
543
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
544
|
+
"User-Agent": getFeishuUserAgent(),
|
|
545
|
+
},
|
|
546
|
+
body: JSON.stringify({
|
|
547
|
+
settings: JSON.stringify({
|
|
548
|
+
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
|
549
|
+
}),
|
|
550
|
+
sequence: this.state.sequence,
|
|
551
|
+
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
|
552
|
+
}),
|
|
553
|
+
},
|
|
554
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
555
|
+
auditContext: "feishu.streaming-card.close",
|
|
556
|
+
})
|
|
557
|
+
.then(async ({ release }) => {
|
|
558
|
+
await release();
|
|
559
|
+
})
|
|
560
|
+
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
|
561
|
+
const finalState = this.state;
|
|
562
|
+
this.state = null;
|
|
563
|
+
this.pendingText = null;
|
|
564
|
+
|
|
565
|
+
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
isActive(): boolean {
|
|
569
|
+
return this.state !== null && !this.closed;
|
|
570
|
+
}
|
|
571
|
+
}
|