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